Repository navigation
fix(billing,security): verified gateway, billing and privacy defects - #52
Merged
Merged
Conversation
The gateway charged callers with `void meterInvocation(...)` — a promise
deliberately not awaited — and the DEBIT ledger entry plus the balance
decrement live inside it, in the same transaction as the Invocation row.
Cloud Run allocates CPU per request by default (the `takoapi` service has
no `--no-cpu-throttling` and no min-instances), so once the response is
flushed a still-pending promise is not guaranteed to run. When the instance
is throttled or reclaimed the transaction is cut short and the Invocation
row and the charge disappear together: a 200 was served, the caller paid
nothing, and nothing recorded that it happened. `billing.ts` also swallowed
every failure in an empty `catch {}`, so even the errors that *were* thrown
left no trace.
- split the transaction body out as `writeInvocation`, and expose two
intents on top of it: `debitInvocation` (money — must be awaited) and
`meterInvocation` (telemetry — safe to fire-and-forget). A billed input
handed to `meterInvocation` is delegated to `debitInvocation`, so a
missed call site still charges correctly.
- replace the empty `catch {}` with `console.error`, so a debit that fails
is visible in Cloud Logging instead of vanishing silently. Neither
function throws: a billing failure must not turn a served call into a 500.
- `/v1/agents/[slug]/message` and `/v1/chat/completions` now `await` the
debit before returning when `billedUsd > 0`; free and failed calls stay
fire-and-forget. Cost: one extra Cloud SQL round-trip (unix socket,
single-digit ms) on billed calls only.
BEHAVIOUR CHANGE: billed calls now block on the debit before the response
is returned.
NOTE: this only closes the window it can close from inside the process.
The stream route is handled separately, and both fixes depend on Cloud Run
being given `--no-cpu-throttling` — an operator action, see the PR body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…der (F2)
The stream route priced a call off `upstream.status` the moment the fetch
promise resolved — before a single SSE byte had been read — and metered it
immediately. Four lines later came:
if (!upstream.body) return 502 "Agent did not return a stream"
so an upstream that answered 200 with no body produced a 502 for the caller
and a paid, status-200 Invocation row on our side. The same root cause hid
every other mid-stream failure: an upstream that broke halfway, a client
that hung up, and the 120s abort all left the early "success" row untouched,
so the stream route's success rate was structurally overstated and could not
be compared with the non-streaming routes, which only price after
`await res.json()`.
Billing now follows the stream's actual outcome:
- no body → `NO_STREAM_BODY`, status 502, billed 0, then the 502.
- otherwise `startInvocation` opens the row unpriced *before* relaying, so a
stream that dies mid-flight still counts in the denominator.
- `settleInvocation` closes that same row when the stream terminates —
updating it rather than inserting a second one, so a stream remains one
Invocation and one callsCount increment. Only a stream that ran to
completion over a <400 response is charged.
- `pull()` is wrapped in try/catch (it had none): a mid-stream break or the
TIMEOUT_MS abort settles as `STREAM_ABORTED`, and `cancel()` settles as
`CLIENT_DISCONNECTED`. Both bill 0, and a `settled` guard keeps the
terminal write exactly-once.
BEHAVIOUR CHANGE, two of them:
1. a partially delivered stream is now free — previously it was charged in
full as soon as the headers looked fine.
2. `Invocation.errorCode` gains three values on this route:
NO_STREAM_BODY / STREAM_ABORTED / CLIENT_DISCONNECTED. No migration —
the column already exists.
NOTE: settlement runs after the response headers are flushed, which makes
this route *more* dependent on Cloud Run keeping CPU allocated than F1 is.
Without `--no-cpu-throttling` this moves the charge to a less reliable
moment rather than a safer one — the two changes must land together with
that operator action. See the PR body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt IP
`checkRateLimit` built every bucket as `${config.key}:${extractClientIp(req)}`,
and the three gateway routes pass `key = gw:<apiKeyId>`. The effective bucket
was therefore `gw:<keyId>:<ip>` — one 120/60s allowance *per IP*, not per key.
`extractClientIp` reads the first element of X-Forwarded-For. Cloud Run and the
GCLB append the real client address to any inbound XFF, so that first element is
supplied by the caller; `src/proxy.ts` cannot normalise it because its matcher
excludes `api|v1|mcp` entirely. A caller who varies the header per request gets
a fresh bucket every time, so the per-key limit did not exist.
That limit is load-bearing for money, not just for load: `checkCreditPreflight`
reads the balance and the debit lands later, with no lock in between, and
billing.ts documents the limiter as the thing that bounds that concurrency
("The shared rate limiter bounds that concurrency"). With the limiter bypassable,
concurrent calls can each pass the same pre-flight read and drive a balance
arbitrarily negative.
- `RateLimitConfig` gains `perIp?: boolean` (default true — unchanged behaviour
for the 11 anonymous endpoints, where the IP is the only identifier available).
- the three `/v1` routes pass `perIp: false`, so an authenticated caller gets one
bucket per API key regardless of what they claim their IP is.
- documented on `extractClientIp` that the value is caller-controlled and must
never be used as identity, authorisation input, or evidence.
BEHAVIOUR CHANGE: a single API key is now limited to 120 requests/60s in total
across all source IPs. A legitimate caller spread over many IPs that previously
got 120/min *each* will start seeing 429s. `ApiKey.rateLimit` (wired up in a
following commit) is the per-key escape hatch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ApiKey` has carried `scopes`, `rateLimit` and `monthlyQuota` since the gateway shipped, and nothing ever read any of them: all 29 `rateLimit` matches in src/ are imports of the limiter module, none is `keyRecord.rateLimit`. Every key got the literal `max: 120`, so tightening one abusive key meant changing the constant for everybody — and the schema advertised a per-key control that did not exist. - `gatewayRateLimit(key)` in src/lib/apikey.ts returns `ApiKey.rateLimit` when it is a positive number, else `DEFAULT_GATEWAY_RATE_LIMIT` (120, the previous literal — so keys with a null override behave exactly as before). - the three `/v1` routes use it. - `scopes` and `monthlyQuota` are marked `NOT ENFORCED YET` in schema.prisma so the columns stop reading as working guarantees. Wiring them needs product decisions (scope taxonomy, month boundary, what happens at the cap) that do not belong in this PR — flagged for follow-up instead. Schema change is comments only — no migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re failures PAYPAL_ENV=live and TAKO_TOPUP_FEE_PCT=5 on the running revision, so this path handles real money. Three ways it could take a payment and not account for it: 1. The fee could be skipped permanently. `captureAndCredit`'s idempotency probe looks only for `type: "TOPUP"` with the order id, but the gross and the fee were two independent `grantCredit` transactions. Crash between them (instance reclaimed, DB blip) and the gross was credited, the fee was not — then the retry found the TOPUP row, returned "already", and the fee was never charged. Now `applyTopUp` writes both ledger entries and the net balance change in one transaction, so "a TOPUP row exists" honestly means "fully settled". 2. Three `return "failed"`s sat *after* `captureOrder` — i.e. after PayPal may already have taken the buyer's money — for a non-COMPLETED capture (a PENDING eCheck, say), an unattributable payment, and a non-positive amount. None wrote a ledger row, logged, or alerted: money in, no credit, nothing to reconcile against. All of them now log the order id, capture id, user and amount, and the crediting call itself is wrapped so a failed write is reported the same way. 3. The webhook discarded `captureAndCredit`'s return value and answered 200 unconditionally, which told PayPal never to redeliver — throwing away the last chance to recover exactly the cases in (2). BEHAVIOUR CHANGE: the webhook now returns 500 when an approved order could not be credited, so PayPal retries it. `captureAndCredit` is idempotent, so a redelivery that finds the money already credited reports "already" and returns 200. A genuinely unrecoverable order will be retried until PayPal gives up (~3 days) and show up as repeated failures in the webhook dashboard — which is the intent: a visible failure beats a silent one. Successful deliveries now echo the outcome in the response body. NOT fixed here: there is still no `PAYMENT.CAPTURE.COMPLETED` subscriber, so a capture that settles later (case 2's PENDING eCheck) is logged but never credited automatically. That needs a new webhook subscription configured on the PayPal side as well as code — flagged for follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`isAuthorizedCron` also accepted `?key=<secret>`. Cloud Run's request log stores the full request URL, query string included, in the _Default bucket with 30-day retention — so a single `curl ".../api/cron/health?key=$CRON_SECRET"`, the most natural way to hand-test one of these endpoints, would have written the secret into a log readable by anyone holding `logging.viewer`. That is a much wider audience than the Secret Manager binding the value is delivered through, and the secret guards five cron routes, one of which (weekly-digest) sends email. Nothing in production uses the query form: all six Cloud Scheduler jobs send `Authorization`, and no doc or script references `?key=`. So the query path was pure downside — a way to leak the secret, with nothing depending on it. - header-only, and `crypto.timingSafeEqual` in place of `===` (cheap, and the right habit for a secret comparison even where the side channel is not practically exploitable across the public internet). BEHAVIOUR CHANGE: `?key=<secret>` no longer authenticates. Anything hand-rolled against it must move to `-H "Authorization: Bearer <secret>"`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
401 / 402 / 429 are all returned before `meterInvocation` runs, and neither the three `/v1` routes nor `/mcp` go through `withRequestLog` — so a rejected call left no Invocation row and no RequestLog row. Any error or success rate computed from the Invocation table is missing precisely the calls the gateway turned away: the denominator excludes our own refusals. Correcting the premise this was filed under: these responses are *not* invisible today. Cloud Run's platform request log records each one with timestamp, status and URL, retained 30 days in the _Default bucket. The real gap is attribution — that log has no userId, no apiKeyId, no agent, no reason, and 402 and 429 are indistinguishable in it beyond the status code, so none of it joins to Invocation. So this adds attribution rather than existence: `logGatewayRejection` writes one structured line that Cloud Run parses into jsonPayload, carrying `reason` (unauthenticated | rate_limited | insufficient_credit), the route *template* (never an interpolated path — keeps log cardinality bounded), and, where known, apiKeyId / userId / agentSlug. The 402 branch also carries `requiredUsd` and `balanceUsd`, which the credit gate has already computed and which are the only quantification of demand lost to an empty balance. Deliberately stdout, not the DB: wrapping these routes in `withRequestLog` would add a write per rejection to a table that has no retention policy and, until the next commit, stores plaintext IPs — i.e. it would make an unbounded-growth and a privacy problem worse in order to fix an observability one. Logging costs nothing per row and is queryable in Logs Explorer today. No behaviour change: responses, statuses and bodies are identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`RequestLog.ip` stored the raw client address, and nothing ever deleted from the
table. The only sweep in the codebase, `maybeClean()` in ratelimit.ts, targets
`RateLimitBucket`; no cron route, script or Scheduler job touched RequestLog. So
plaintext personal data accumulated indefinitely on a db-f1-micro with a 10 GB
disk, with no expiry and no purge path.
The stored value was not even dependable: it is the first element of a
caller-supplied X-Forwarded-For (Cloud Run appends the real address rather than
replacing the header), so it was a privacy liability that could not serve as
evidence either — bad on both counts, which is why hashing alone is not the whole
fix here.
- `hashClientIp` writes a salted SHA-256 truncated to 128 bits, with the UTC date
mixed in, so the same address hashes differently each day. That keeps the one
question the column was good for ("same source?") within a day and drops
cross-day correlation without extra deletion machinery.
- No salt, no value: with TAKO_IP_SALT unset the column is left null and the
process warns once. An unsalted digest over a 32-bit address space is brute-
forceable in seconds, so a reversible hash would be worse than nothing.
- New `/api/cron/retention` route, guarded by `isAuthorizedCron`, deletes rows
older than 90 days in batches of 5k, capped at 20 batches per run so it cannot
overrun `maxDuration`. It reports `exhausted` so a backlog that outlives one run
is visible instead of looking like a clean sweep. PostgreSQL's DELETE takes no
LIMIT, so the batching goes through `ctid IN (SELECT ... LIMIT n)` — the exact
trap F8 flagged for whoever implemented this.
MIGRATION 010 IS DESTRUCTIVE ON PURPOSE: it DROPs "ip" instead of renaming it.
Renaming would keep the plaintext addresses this change exists to remove, under a
name claiming they were hashed. Nothing reads the column (no `.ip` reader in
src/), so it is code-safe — but snapshot the rows first if they are wanted for an
investigation, because after `prisma migrate deploy` they are gone.
Two operator prerequisites, neither doable from a PR: create the TAKO_IP_SALT
secret and bind it to the service, and create the Cloud Scheduler job that calls
this route. Until the salt exists ipHash is null; until the job exists the route
is inert. Both are in the PR body.
NOT fixed here: `userAgent` is still stored in full (truncated to 500 chars).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est value (F7) `Agent.healthStatus` / `healthCheckedAt` are the only health state there is, and the hourly cron overwrites them in place. There is no history model anywhere in the schema — grep for health in schema.prisma returns exactly those two lines. So "this agent is down right now" was answerable and "since when", "for how long", and "what was its availability last week" were not, for any agent, ever. - new `AgentHealthCheck` (agentId, status, previousStatus, checkedAt) + migration 011. - `runHealthChecks` now selects `healthStatus` alongside the probe targets and appends a row only when the probe disagrees with the stored value. Writing every probe would add (agents x 24) rows a day forever on a 10 GB shared-core instance to answer the same questions; recording flips keeps the table proportional to how often agents actually change, and makes "when did it go down" a single lookup — the `checkedAt` of the newest row with status='down'. - the agent update and the history insert go in one transaction, so the stored status and its history cannot disagree. The existing per-agent error isolation is kept: a failed write increments `writeErrors` (and backs out its `transitions` count) rather than aborting the batch. - `HealthSummary` gains `transitions`, and the cron route logs the summary as one structured line. It was previously returned to Cloud Scheduler, which discards response bodies — so even the per-run totals were being thrown away. No behaviour change to what the endpoint returns, beyond the added field. FOLLOW-UP not done here: this table needs a retention policy of its own. The 90-day sweep added for RequestLog covers only that table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…all data (F6)
`Skill.downloads` is a number scraped off each skill's clawskills.sh page
(scrape-clawskills.ts takes `texts[4]` by DOM position; daily-sync.ts overwrites
it nightly via the takoapi-daily-sync Scheduler job). Nothing in this repo ever
increments it — install.sh, packages/takoapi-install and takoapi_skill/ report
nothing back — so it carries no information about installs through TakoAPI. It is
also pre-rounded at the source, where "1.2k" becomes 1200.
The trending page nonetheless described it, in all 15 locales, as "the
most-downloaded ... right now — a live leaderboard of what coding agents are
installing". Three claims, none true: it is a T-1 snapshot, not live; it is not
"right now"; and it is not what agents installed, here or anywhere we can see.
- `metaDescription` and `ogDescription` in all 15 locales now attribute the
numbers to clawskills.sh and say "daily" instead of "live". Values only — no key
added, renamed or removed, so `check:i18n` still reports 618 keys across 14
locales matching en.json.
- `Skill.downloads` gets a schema comment stating where the number comes from and
that it must not be summed into a site metric — the admin panel does exactly
that today (`_sum: { downloads: true }` surfaced as `totalDownloads`).
Comment-only schema change; no migration.
DELIBERATELY NOT DONE: renaming the column to `externalDownloads`. It is the
cleaner fix and it is what the finding recommends, but it means a column rename
across ~15 call sites plus a migration, and deploys here are a manual
`gcloud run deploy` with no CI build behind them — during the rollout window the
old revision would be querying a column that no longer exists. Not worth taking
that risk inside a batch of billing changes. Flagged for a PR of its own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ected This plan is what prompted the verification pass the rest of this PR came out of. It was drafted against a working tree 18 commits behind origin/main, so before committing it its claims were re-checked against origin/main = b868319 and, where they concerned production, against read-only gcloud output. Corrected before landing: - §0.1's "cloudbuild.yaml has drifted from production; running it once would overwrite the production secrets with placeholders" — REFUTED, and it was listed as a *foundation* of the plan. Wrong twice over: PR #51 rewrote that file on origin/main to use --update-env-vars + --update-secrets, and even the old file would not have overwritten anything, because gcloud raises ConfigurationError client-side when a literal collides with an existing secretKeyRef. The placeholder strings existed only in the stale local tree. Struck through in the table with the full explanation below it, and §2.3's warning block, §8's secret note, the Phase 0 blocker and the file list all rewritten. The conclusion "editing cloudbuild.yaml does not change production" survives — for the simpler reason that no trigger runs it. - H3's "401/402/429 leave no record at all" — narrowed. The platform request log does record them (timestamp/status/URL, 30-day retention); what is missing is attribution. The event is still worth building; its value proposition changes from "from nothing to something" to "from unattributable to attributable". - Two §0.4 open questions closed with measured values: _Default retention is 30 days, and Cloud Run has an empty cpu-throttling annotation (i.e. throttled), no minScale, maxScale=20, containerConcurrency=80. Added §0.6, a table of the statements the rest of this PR has already made obsolete, so a reader is not misled by prose describing the pre-fix code. Also redacted for a public repo: the local filesystem path to the source blueprint, and the other product's name in the comparisons — neither belongs in a public MIT repo. Paths in the doc are now repo-relative. Verified against: origin/main b868319 (2026-08-08). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both new migrations carried no instructions for how they actually reach the
production database, and the PR description said `prisma migrate deploy` —
which docs/agent-marketplace/HANDOFF.md §5/§6 explicitly rules out for this
repo: the `0_init` baseline directory sorts after every `0NN_` directory
('1' < '_'), so deploy would replay the whole history and end by re-running
the baseline, and the production `_prisma_migrations` table is documented as
an unreliable record of what is already applied. The documented procedure is
`prisma db execute --file <migration.sql>`, one file at a time.
Also records the deploy ordering each one needs, which differs:
- 010 is order-insensitive. `logRequest` catches its own write failure and
never throws, so whichever side is ahead only costs RequestLog rows.
- 011 must be applied BEFORE the new revision. `runHealthChecks` inserts the
history row in the same transaction as the Agent update, so if the code
ships first, every status flip in the gap rolls the whole transaction back:
the flip is lost AND `Agent.healthStatus` is left stale, counted only as a
writeError.
Comments only — no schema or statement changes. Both files were already
idempotent (IF EXISTS / IF NOT EXISTS), which is what makes the one-file-at-a-
time procedure safe to re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
oratis
added a commit
that referenced
this pull request
Sep 7, 2026
Server-rendered catalog, closed developer funnel, visible pipeline failures. Carries #52 (gateway/billing/privacy fixes) as ancestor commits, plus the fixes from a second 10-dimension adversarial review.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Where this came from
Writing a telemetry plan for TakoAPI (
docs/analytics-plan-2026-08-21.md, last commithere) meant reading the gateway and billing paths closely. That turned up a set of
defects that had nothing to do with telemetry. Each one was then independently verified
against
origin/mainand, where it concerned production, against read-onlygcloudoutput. This PR fixes the ones a PR can fix.
Two of the findings were refuted during verification and are deliberately NOT
"fixed" here — see Not changed, on purpose below. One of them was refuted because
the plan had been drafted on a working tree 18 commits behind
origin/main; thatcorrection is part of the doc commit.
Branch is cut from
origin/main=b868319c.Three commits change how and when money moves. Nothing here changes a price — but
they change when a charge is written, whether it can silently not happen, and who gets
rate-limited.
void meterInvocation(...), response returned without waitingawait debitInvocation(...)before the response. Costs one Cloud SQL round-trip on billed calls only<400responsegw:<keyId>:<ip>— 120/60s per IP, and the IP is caller-suppliedgw:<keyId>— 120/60s per key, in total. A caller legitimately spread across many IPs will start seeing 429s;ApiKey.rateLimitis the per-key escape hatch (now honoured, see below)captureAndCreditis idempotent, so a redelivery finding the money already credited answersalready+ 200?key=<secret>no longer authenticates cronAuthorizationheader or query string🔴 #1 and #2 share one operator prerequisite that this PR cannot satisfy: Cloud Run
must be given
--no-cpu-throttling. The service currently has no such annotation, soCPU is only allocated during request handling. #1 improves matters regardless (the
debit now completes while the request is still open). #2 does not — stream
settlement inherently runs after the response headers are flushed, so moving the charge
to the end of the stream makes it more dependent on CPU staying allocated, not less.
Without that flag, #2 relocates the charge to a less reliable moment. Please apply the
flag before or together with merging this.
What's fixed
Each finding is one commit.
Money
F1 —
fix(billing): await the credit debit before respondingThe DEBIT ledger entry and balance decrement lived inside a promise that was
deliberately not awaited, in the same transaction as the
Invocationrow. On athrottled or reclaimed instance both vanish together: a 200 served, nothing charged,
nothing recorded.
billing.tsalso swallowed every failure in an emptycatch {}.Split into
debitInvocation(money, awaited) andmeterInvocation(telemetry,fire-and-forget); empty catch replaced with
console.error.F2 —
fix(billing): charge SSE streams when the stream endsThe stream route priced the call off
upstream.statusbefore reading a byte, thenfour lines later could return 502 for a missing body — leaving a paid, status-200
row behind a failed request. Mid-stream breaks, client disconnects and the 120s abort
were likewise invisible. Now:
startInvocationopens the row unpriced beforerelaying,
settleInvocationcloses that same row at termination (so a stream isstill one Invocation and one
callsCount),pull()gained the try/catch it neverhad, and three
errorCodevalues are introduced —NO_STREAM_BODY,STREAM_ABORTED,CLIENT_DISCONNECTED(column already exists, no migration).fix(billing): stop losing the PayPal top-up fee(PAYPAL_ENV=live— real money)The idempotency probe looks for a
TOPUProw, but gross and fee were two separatetransactions. A crash in between left the fee unwritten, and the retry then saw the
TOPUP row, said "already", and never charged it. Merged into one transaction
(
applyTopUp). Separately, threereturn "failed"paths sat after the PayPalcapture — non-COMPLETED capture, unattributable payment, non-positive amount — and
wrote nothing, logged nothing, alerted nothing. All now log with full context.
Security
fix(security): stop bucketing gateway rate limits by a spoofable client IPextractClientIpreads the first element of X-Forwarded-For, which Cloud Runappends to rather than replaces — so it is caller-controlled, and
src/proxy.tscannot normalise it (its matcher excludes
api|v1|mcp). Varying the header perrequest minted a fresh bucket each time. This matters for money, not just load:
checkCreditPreflightreads the balance with no lock before the debit, andbilling.tsdocuments the limiter as the thing bounding that concurrency.RateLimitConfiggainsperIp(defaulttrue, unchanged for the 11 anonymousendpoints); the three
/v1routes passfalse.fix(security): accept CRON_SECRET only in the Authorization header?key=<redacted>was accepted, and Cloud Run logs full URLs including query stringswith 30-day retention — so one hand-rolled
curlwould have copied the secret into alog readable by anyone with
logging.viewer, a far wider audience than the SecretManager binding. Nothing used the query form (all six Scheduler jobs send the
header). Comparison also moved to
crypto.timingSafeEqual.F5 —
⚠️ Migration
fix(privacy): hash RequestLog client IPs and add a retention sweepRequestLog.ipstored raw addresses and nothing ever deleted from the table — theonly sweep in the codebase targets
RateLimitBucket. Now a salted SHA-256 with theUTC date mixed in (so correlation is bounded to one day), plus a new
/api/cron/retentionroute deleting rows older than 90 days in batches.010is destructive on purpose: it DROPsiprather than renamingit — renaming would preserve the plaintext this change exists to remove, under a
name claiming it was hashed. Nothing reads the column. Snapshot the rows first if
they are wanted for an investigation.
feat(gateway): honour ApiKey.rateLimitscopes,rateLimitandmonthlyQuotahad existed since the gateway shipped withnothing reading any of them.
rateLimitis now honoured (null → 120, the previousliteral, so existing keys are unaffected).
scopesandmonthlyQuotaare markedNOT ENFORCED YETin the schema rather than left looking like working guarantees.Correctness / observability
F4 —
feat(gateway): emit attributable telemetry for rejected callsCorrecting the finding as filed: 401/402/429 are not unrecorded — Cloud Run's
platform log has them. What is missing is attribution: no userId, no apiKeyId, no
agent, no reason, and 402/429 are indistinguishable there. So this adds a structured
stdout line (
reason, route template, apiKeyId/userId/agentSlug, plusrequiredUsd/balanceUsdon 402) rather than DB rows — wrapping these routes inwithRequestLogwould have added a write per rejection to an unbounded table.No response, status or body changes.
F7 —
feat(health): record agent health transitionsAgent.healthStatusis overwritten hourly and there is no history model anywhere, so"since when has this agent been down" had no answer. New
AgentHealthCheckwrittenonly on a status flip, keeping the table proportional to flips rather than
(agents × hours).
HealthSummarygainstransitions; the cron route now logs thesummary, which was previously returned to Cloud Scheduler and discarded.
F6 —
docs(content): stop presenting scraped ClawSkills counts as live install dataSkill.downloadsis scraped from clawskills.sh and refreshed nightly; nothing hereever increments it. The trending page nonetheless called it, in all 15 locales, "the
most-downloaded … right now — a live leaderboard of what coding agents are
installing" — not live, not now, and not installs. Copy corrected in all 15 locales
(values only;
check:i18nstill reports 618 keys matching) and the columndocumented in the schema.
Docs
docs(analytics): add the telemetry plan, with its refuted claims correctedAlso redacts the local filesystem path and the other product's name that the draft
carried, since this repo is public.
Not changed, on purpose
cloudbuild.yamldrift) — REFUTED, no code change. PR Salvage: cloudbuild safety + tsx devDependency (May 10 local commits) #51 already fixed thatfile on
origin/main; the placeholder strings existed only in a stale localcheckout. And even the old file would not have "overwritten production secrets" —
gcloud raises
ConfigurationErrorclient-side when a literal collides with anexisting
secretKeyRef, so the deploy step would have failed without writinganything. Corrected in the plan doc.
prisma/sqlpreview feature,DELETE … LIMIT) — REFUTED, no code change.Neither trap is present. Both were real constraints for the new retention sweep
though, which is why it uses
ctid IN (SELECT … LIMIT n).Deliberately deferred (reasons, not oversights)
User.apiKeystored in plaintext. Real, and it is a full admin path viarequireAdmin. Not fixed here because it needs a credential migration: five lookupsites, a schema change, a backfill on the live DB, and re-issuing every existing key.
Hashing only new keys would silently break every current one. Needs its own PR plus
an operator step.
Skill.downloads→externalDownloads. The cleaner fix, but a columnrename across ~15 call sites with a manual
gcloud run deployand no CI build meansthe old revision would query a dropped column during rollout. Not worth bundling with
billing changes.
ApiKey.scopes/monthlyQuotaenforcement. Needs product decisions (scopetaxonomy, month boundary, behaviour at the cap).
RequestLog.userAgentis still stored in full.PAYMENT.CAPTURE.COMPLETEDwebhook subscriber. A PENDING capture that settleslater is now logged but still not credited automatically; adding this needs a new
subscription configured on the PayPal side too.
🔧 Operator actions required (this PR cannot do these)
--no-cpu-throttlingon Cloud Run — blocking for F2, strongly wanted for F1.Without it, moving stream billing to stream-end makes the charge less reliable.
Changes the billing model (always-on CPU costs more), so it needs a human decision.
cloudbuild.yamluses--update-*and will not clobber it.010and011(prisma migrate deploy).010dropsRequestLog.ipand its data — snapshot first if it is wanted.TAKO_IP_SALTsecret and bind it to the service. Until it exists,ipHashis written as null (deliberately — an unsalted hash of a 32-bit addressspace is trivially reversible).
/api/cron/retentionwithAuthorization: Bearer <redacted>, same shape astakoapi-health. Until then theroute is inert and
RequestLogkeeps growing.handles it; if a snapshot is taken first, delete the snapshot on a schedule.
CRON_SECRETif it was ever passed as?key=in a shell, CI job orbrowser. Check Cloud Run logs for
/api/cron/*URLs carrying a query string.?key=before merging — it stops working.User.apiKeycredential migration (see above); it is the weakestcredential in the system and it grants admin.
package-lock.json— CI has been broken onmainsince 2026-08-08.npm cifails on@emnapi/*and@swc/helpersdesync, on GitHub Actions(linux/node-20) and locally alike, so the
i18nworkflow has not actually runcheck:i18non any commit sinceb868319c. Needsnpm installon a machine whoseplatform matches CI, then a check that both
npm ciand the Docker build stillwork. Pre-dates this PR; deliberately kept out of it.
Verification
No test framework exists in this repo, so:
npx tsc --noEmit(whole project)npm run build(next build)/api/cron/retentionregisters as dynamicnpx eslinton all 14 changed.tsfilesnpm run check:i18n(the only CI-enforced check)All 14 locales match en.json (618 keys)npx prisma validatenpx eslint src/reports 18 pre-existing errors, allreact-hooks/set-state-in-effectin admin/dashboard pages not touched by this PR (verified against the changed-file
list). They exist on
origin/mainunchanged.Secrets:
git diff origin/mainwas read through and grepped forpassword|secret|token|private_key|BEGIN … PRIVATE|api[_-]?key|passwd|credential.Every hit is a variable name, an env-var name, or prose. No literal credential values
are added. Secret values are referred to as
<redacted>throughout.🤖 Generated with Claude Code