Skip to content

Implement the UX and technical review: server-rendered catalog, closed developer funnel, visible pipeline failures - #54

Merged
oratis merged 23 commits into
mainfrom
claude/optimize-2026-09
Sep 7, 2026
Merged

oratis merged 23 commits into
mainfrom
claude/optimize-2026-09

Conversation

@oratis

@oratis oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Implements the review in #53. Also contains #52 (gateway/billing/privacy fixes) merged in, so #52 can be closed in favour of this one — or merged first, and this rebased.

170 files, +23.5k/−6k. tsc, eslint, vitest (158 tests), check:i18n, check:secrets and next build all pass; CI now runs every one of them.


⚠️ Two things this PR cannot fix for you

  1. The production database password is in git history. scripts/deploy-sync-job.sh carried postgresql://postgres:…@… from 2026-07-04 in a public repo, and docs/03-secret-hardening.md records that step 5 (rotation) was never done. This PR removes the literal and binds the value from Secret Manager, but the password must be rotated and the history purged — removing it from the tip does nothing for anyone who already cloned. npm run check:secrets now fails the build on any recurrence.
  2. APPLE_CLIENT_SECRET expires 2026-09-25. After that, Apple sign-in fails silently — no error page, no log line. It needs re-signing (Apple caps the JWT at 6 months). docs/06-cron-schedule.md now carries a key-expiry calendar so this stops being tribal knowledge.

What changed

The catalog is server-rendered again

/skills, /skills/[slug], /trending and /scenarios were client components that fetched after mount, so the entry points to 5,385 skills served crawlers an empty shell — no <h1>, no body text. Filters lived only in React state, so results could not be shared, did not survive a refresh, and the back button did nothing. All four are now Server Components reading searchParams, every control is a link, pagination has real page numbers, and filtered permutations are noindex.

/agents keeps its server rendering but trades a 40-chip wall for a filter sidebar, active-filter chips you can remove, and a sort default that depends on kind — calls for hosted agents, stars for projects — instead of likesCount, which is ~0 on every row and made "popular" a shuffle.

The agent page answers "should I call this?"

Call samples in curl, Python, TypeScript and raw A2A from one shared source. A health badge with its last-checked time. For open-source entries, real GitHub metadata and a README excerpt behind a six-hour cache, so a project page is worth more than the repo link it used to be. The "Signed card" and "Verified publisher" badges are gone: nothing ever wrote those fields, so they could never appear.

Publishing and moderation close the loop

A publisher can validate an AgentCard before submitting and see a preview of what will be listed; errors land on the field that caused them instead of in one red bar. Submission status — including the reviewer's note — is now visible on /profile, which lists agents and skills together. Approving or rejecting sends an email. Previously a publisher submitted into silence and had no way to learn the outcome.

Admin mutations validate their bodies against the zod schemas that had been sitting unused in src/lib/schemas.ts, and invalidate the catalog cache so an approval appears immediately.

Auth

Credentials login had no rate limit at all — /api/* is outside the proxy matcher and NextAuth owns the route, so password guessing was free. Now 8 attempts per 15 minutes per account+IP, checked before the user lookup so a flood costs neither a query nor a bcrypt compare. Password reset and email verification are built on the VerificationToken table, which had existed unused since the first migration; only token digests are stored.

The gateway

/v1/chat/completions honours stream: true (translating A2A SSE into OpenAI chunks, charging only a stream that runs to completion), sends the whole messages array instead of just the last user turn, and reports usage. /v1/agents/{slug}/message accepts full A2A messages with taskId/contextId so a multi-turn task can continue, and rejects an empty message before calling — and billing — the upstream.

The content pipeline stops failing silently

The catalog's newest skill was dated 2026-07-04 while the sync job exited 0 every night: clawskills.sh changed its markup and the scraper matched nothing. Ingestion runs now emit a structured log line, return non-2xx when a run found nothing at all, and a new /api/cron/ingestion-health reports per-source staleness — the signal that would have caught this on day one.

Security and privacy

Security headers (HSTS, nosniff, frame-ancestors, Referrer-Policy, Permissions-Policy), poweredByHeader off, and CORS scoped to the token-authenticated machine surfaces. Public API responses go through a whitelist: reviewNote, publisherId and publisher avatars are no longer served. GA4 no longer loads on first paint — the site serves German, French, Spanish and Italian, and there was neither notice nor a way to decline.

Performance

Tag-cached catalog reads replace nine uncached queries per home-page render; public GETs send s-maxage + stale-while-revalidate; listings stop selecting the README column. Migration 014 adds trigram GIN indexes for the ILIKE '%q%' searches that no index could serve — every catalog search was a sequential scan — plus the GIN index on Agent.scenarios that migration 007 deferred at ~500 rows and now backs every scenario filter over 1,600.

Correctness

  • Home page, /api/agent and the weekly digest now filter status = APPROVED. Pending and rejected skills appeared in all three.
  • installCmd is only written for skills that exist on ClawHub; migration 012 nulls the fabricated clawhub install <slug> commands already in the table, which failed for anyone who copied them.
  • GET /api/skills/[id] no longer increments viewsCount — every crawler and prefetch was a "view". Views are recorded by the page, deduplicated per visitor per day.
  • The like button reads back whether you already liked it. It previously assumed "not liked", so a second visit's click silently removed your like.

Engineering

158 unit tests, from zero: billing arithmetic and its deliberate fail-open, the AgentCard SSRF guards, scenario classification's word-boundary rules ("art" must not match "smart"), callback-URL validation, pagination clamping, view dedupe. CI runs secrets, i18n, skill-assets, typecheck, lint, test and build — the old workflow ran only i18n and had been failing since 2026-08-08, so nothing was being checked. npm ci works again. scripts/build-skill-assets.mjs generates the five copies of the skill body from one source; they had already drifted.

i18n

All 14 non-English catalogs retranslated against the rewritten English rather than patched for missing keys — the positioning changed, so surviving keys had stale translations. Plural categories now follow each language (Russian gets few/many; Chinese, Japanese and Korean drop the single-branch wrappers that only mirrored English grammar). Admin.scenarioOption was passing a {nameZh} placeholder the English string no longer has.


Two bugs found while integrating

  • <Analytics> sat outside NextIntlClientProvider. Once the consent banner called useTranslations, that threw during prerender and broke the production build for every static page. Moved inside the provider.
  • InstallTabs.opencodeUse contained a bare <query>, which ICU parses as an unclosed tag and throws on when formatted. check:i18n never caught it because it skips ICU validation for keys whose English value does not parse — so the one broken English string was the one string it would not check.

Follow-ups deliberately not in this PR

User.apiKey is still stored in plaintext (the admin x-api-key path). ApiKey.scopes and monthlyQuota are still not enforced. Both are called out in #53 and want their own change.

🤖 Generated with Claude Code

oratis and others added 19 commits August 21, 2026 12:13
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>
…ched catalog

Security & ops (T0):
- scripts/deploy-sync-job.sh no longer carries the production DB password; it
  binds DATABASE_URL from Secret Manager. The old literal must still be rotated.
- scripts/check-secrets.mjs + a CI step fail the build on credential-shaped
  values in tracked files.
- next.config.ts: HSTS, nosniff, frame-ancestors/X-Frame-Options,
  Referrer-Policy, Permissions-Policy, poweredByHeader off; CORS on the
  token-authenticated machine surfaces (/v1, /mcp, public catalog GETs).
- Public API responses go through a whitelist (lib/agent-dto): reviewNote,
  publisherId and publisher avatars are no longer served.

Correctness:
- Home page, /api/agent and the weekly digest now filter status=APPROVED;
  pending and rejected skills used to appear in all three.
- installCmd is only written for skills that exist on ClawHub, and migration 012
  nulls the fabricated ones already in the table.
- GET /api/skills/[id] no longer increments viewsCount; views are recorded by the
  page via POST .../view, deduplicated per visitor per day (lib/views).
- /api/agent: case-insensitive search, pagination, cacheable.

Performance:
- lib/catalog: tag-cached catalog reads (home, scenario counts, categories)
  replacing nine uncached queries per home-page render.
- Public catalog GETs send s-maxage + stale-while-revalidate.
- Listings omit the README column.

UX:
- One search box with an agents/projects/skills scope (SiteSearch), replacing
  two boxes that went to different places.
- Header: agents-first nav, a standing "Get API key" action, click-driven
  account menu with ARIA state, skip link.
- Sign-in and sign-up carry callbackUrl, so gated pages return you where you were.
- Branded 404, error and loading states for every locale route.
- Profile is now "My listings": agents and skills together, with moderation
  status and the reviewer note (publishers previously had no way to see either).
- Home page: value-first hero, a 30-second quickstart, health-filtered featured
  agents, only scenarios with real depth, one skills section instead of four.
- Email templates speak the current product; added verification, password reset
  and review-outcome templates.

Engineering:
- Regenerated lockfile so npm ci works; .nvmrc and engines pin Node 20.
- .github/workflows/ci.yml runs secrets, i18n, skill-asset, typecheck, lint, test
  and build. The old workflow only ran i18n and had been failing since 2026-08-08.
- scripts/build-skill-assets.mjs generates the five copies of the skill body from
  takoapi_skill/SKILL.md, which had already drifted apart.
- A shared Engagement component replaces the duplicated agent and skill bars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- prisma/migrations/014: trigram GIN indexes on the columns every search filters
  with ILIKE '%q%', which no existing index could serve — each catalogue search
  was a sequential scan over 5.4k skills or 1.6k agents on a shared-core
  instance. Plus a GIN index on Agent.scenarios (migration 007 deliberately
  skipped it at ~500 rows; it now backs every scenario tile and filter) and the
  status+sort composites the listings actually order by.
- Analytics: GA4 no longer loads on first paint. The site serves German, French,
  Spanish and Italian, so a real share of visitors are covered by the ePrivacy
  directive and there was neither notice nor a way to decline. Nothing loads
  until the visitor chooses; declining is one click and is remembered.
- GET /api/skills/[id]/like now reports whether the viewer already liked it. The
  detail page assumed "not liked" on every load, so an already-liked visitor
  clicking the heart silently removed their like and showed a count one low.
- /api/usage returns a 14-day daily series (grouped in Postgres, gaps filled) so
  the dashboard can show a trend instead of the last 15 rows.
- authenticateApiKey throttles the lastUsedAt write to once a minute per key
  instead of once per gateway call.
- withAdmin distinguishes 401 (not signed in) from 403 (signed in, not an admin);
  both used to be a 403 whose body said "Unauthorized".
- /api/user/skills goes through the shared session-or-key resolver.
- Contrast: text-gray-400 on real text fails WCAG AA on white; raised to
  gray-500 across the components not being rewritten elsewhere in this branch.
- docs/06-cron-schedule.md: the new retention and ingestion-health jobs, the
  alerts that would have caught the two-month silent ingestion outage, and a
  key-expiry calendar (Apple's client secret expires 2026-09-25).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the new shared code

useAsync wrote to a ref during render (React 19 forbids it) — the caller already
declares its dependencies through `deps`, so the ref was never needed. Analytics
read localStorage into state from an effect; consent is external state, so it now
goes through useSyncExternalStore, which also keeps the server and first client
render agreed on "not answered yet".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e the pipeline's failures visible

Sixteen tracks from the review, plus the fixes needed to make them build together.

## Discovery pages are server-rendered again
/skills, /skills/[slug], /trending and /scenarios were client components that
fetched after mount, so the entry points to 5,385 skills served crawlers an empty
shell, and filters lived only in React state — unshareable, lost on refresh, dead
to the back button. All four are now Server Components reading searchParams, with
every control a link, real page numbers, and noindex on filtered permutations.
/agents keeps its server rendering but trades a 40-chip wall for a filter sidebar,
active-filter chips, and a sort default that depends on kind (calls for hosted
agents, stars for projects) instead of likesCount, which is ~0 everywhere.

## The agent page answers "should I call this?"
Multi-language call samples (curl, Python, TypeScript, A2A) from one shared source,
a health badge with its last-checked time, and — for open-source entries — real
GitHub metadata and a README excerpt behind a six-hour cache, so a project page is
worth more than the repo link it used to be. The two verification badges that
could never appear are gone.

## Publishing and moderation close the loop
Publishers can validate an AgentCard before submitting (a preview of what will be
listed), see field-level errors instead of one red bar, and watch status on
/profile. Approving or rejecting now emails them with the reviewer's note. Admin
mutations validate their bodies against the zod schemas that had been sitting
unused, and invalidate the catalog cache so an approval shows up immediately.

## Auth
Credentials login had no rate limit at all — /api/* is outside the proxy matcher
and NextAuth owns the route, so password guessing was free. Now 8 attempts per 15
minutes per account+IP, checked before the user lookup. Password reset and email
verification are built on the VerificationToken table, which had existed unused
since the first migration; only token digests are stored.

## The gateway
/v1/chat/completions honours stream:true (translating A2A SSE into OpenAI chunks,
charging only a stream that completes), sends the whole message array instead of
just the last user turn, and reports usage. /v1/agents/[slug]/message accepts full
A2A messages with taskId/contextId so a multi-turn task can continue, and rejects
an empty message before calling — and billing — the upstream.

## The pipeline stops failing silently
The catalog's newest skill was dated 2026-07-04 while the sync job exited 0 every
night: clawskills.sh changed its markup and the scraper matched nothing. Ingestion
runs now emit a structured line, return non-2xx when a run found nothing at all,
and a new /api/cron/ingestion-health reports per-source staleness — the signal that
would have caught this on day one.

## Also
- /docs: the API reference the funnel had no answer for.
- Dashboard: onboarding checklist, named keys, a 14-day usage chart.
- Admin screens rewritten off setState-in-effect, with in-page dialogs replacing
  prompt()/confirm() and DISABLED moderation exposed.
- Sitemap excludes what the app itself marks noindex, so Google is not sent to
  pages that tell it to go away.
- 158 unit tests, from zero: billing arithmetic and its deliberate fail-open,
  the AgentCard SSRF guards, scenario classification's word-boundary rules,
  callback-URL validation, pagination clamping, view dedupe.

## Build fixes found while integrating
- <Analytics> sat outside NextIntlClientProvider in the root layout. Once the
  consent banner started calling useTranslations, that threw during prerender and
  broke the production build for every static page. Moved inside the provider; its
  server snapshot renders nothing, so the banner is never baked into static HTML.
- InstallTabs.opencodeUse contained a literal <query>, which ICU parses as an
  unclosed tag and throws on. check:i18n never caught it because it skips ICU
  validation for keys whose English value does not parse.
- check-secrets matched its own patterns and doc placeholders; the heuristics now
  recognise placeholder credentials while still catching real ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… does

The reference omitted the kind filter, the hosted/project distinction that decides
whether an agent is callable at all, streaming on the OpenAI shim, the rate limit
and when a call is billed. Adds the check commands CI runs and the skill-asset
generation step, which is easy to miss and silently drifts five files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
text-gray-400 on white is about 2.5:1 and fails WCAG AA. The copy button inside
the dark command bar needed the opposite move — lighter, not darker — which a
blanket replace would have got backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…link /docs

The English copy changed substantially in this branch — the positioning moved from
"OpenClaw skills marketplace" to "one API to access all agents", stale strings were
corrected, and 316 keys were added across the new pages. A key surviving does not
mean its translation survived: every value was re-checked against the current
English, not just the missing ones. Each catalog is now 961 keys, key-for-key with
en.json, ICU-valid.

Notable fixes that fell out of the pass:
- Plural categories now follow each language rather than copying English's
  one/other. Russian gets few/many, Arabic its six forms; Chinese, Japanese,
  Korean, Indonesian and Vietnamese drop the single-branch plural wrappers that
  were only ever there to mirror English grammar.
- Admin.scenarioOption took a {nameZh} placeholder that no longer exists in the
  English string. Any locale still passing it would have rendered a literal.
- Admin.scenariosEditorHint was half-Chinese in all fifteen files, including the
  English one.
- InstallTabs.opencodeUse contained a bare <query>, which ICU reads as an
  unclosed tag and throws on when the message is formatted.

/docs, added earlier in this branch, had no way in: it is now in the header (in
place of Blog, which stays in the footer) and in the footer's resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… Linux

The lockfile regenerated earlier in this branch was resolved against an existing
macOS node_modules, so npm recorded only the platform variants present there:
CI on node:20 failed with '@emnapi/runtime, @emnapi/core, @swc/helpers missing
from lock file'. Resolving from a clean tree records every platform's optional
dependencies, and npm ci now succeeds on both npm 10/node 20 and npm 11/node 24.

check-secrets: a value made of one repeated character is a shape, not a secret
(the docs page shows tako_live_xxxxxxxx… as the key format). Verified against a
random-looking key, which is still caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis

oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Deploying this

CI is green. Beyond the usual deploy, this branch needs five migrations applied by hand, in order. docs/agent-marketplace/HANDOFF.md §6 records that the production _prisma_migrations table is not a reliable account of what has been applied, and the 0_init baseline directory sorts after every 0NN_ directory, so prisma migrate deploy would replay the whole history. Use db execute:

for m in 010_requestlog_ip_hash 011_agent_health_history 012_installcmd_cleanup 013_password_reset 014_search_indexes; do
  npx prisma db execute --url "$PROD_URL" --file "prisma/migrations/$m/migration.sql"
done

Every file is idempotent, so a re-run is safe. Two need their ordering respected relative to the code deploy, and each explains why in its own header comment:

Migration What it does Order
010 Replaces RequestLog.ip with a salted hash. Drops the column — snapshot first if those rows are wanted. Either side; a mismatch only loses log rows
011 Adds AgentHealthCheck Before the deploy — the health cron writes it inside the same transaction as the agent update, so running the new code first fails every status flip
012 Nulls the fabricated clawhub install … commands Either side (data only)
013 Indexes VerificationToken for password reset Before the deploy
014 Trigram GIN indexes + Agent.scenarios GIN Either side; takes a brief ACCESS EXCLUSIVE lock, well under a second at this row count

Environment

  • TAKO_IP_SALT — required by 010, or RequestLog.ipHash stays null. Any long random string; rotating it re-anonymises past rows.
  • --no-cpu-throttling on the Cloud Run service. I confirmed the annotation is currently absent, which means work scheduled after the response is flushed is not guaranteed to run. This branch awaits the billing debit, but request logging and lastUsedAt are still best-effort without it.

Scheduler

docs/06-cron-schedule.md carries an idempotent create-or-update script for all seven jobs, including the two new ones (tako-retention, tako-ingestion-health). It also documents the three Cloud Monitoring alerts worth having — one of them, "catalog stale", is what would have caught the two-month ingestion outage on day one instead of two months in.

oratis and others added 2 commits September 7, 2026 12:38
…e limits, lost charges

An 8-dimension review with 3 independent skeptics per finding raised 43 defects;
32 survived verification. This fixes all 4 high-severity ones and every medium.

## Authorization
- **A gateway key could act as an admin credential.** Routing the admin gate
  through the shared `resolveApiUser` meant any `tako_live_…` key belonging to a
  user with role=admin reached /api/admin/* — including role changes and the full
  user list with emails. Those keys are meant to be handed out: the docs tell
  users to paste one into an MCP client's config, and /api/keys mints them from
  any session. The admin surface now requires a session, the legacy
  single-purpose `User.apiKey`, or a key that explicitly names the `admin` scope
  (nothing issues one). `hasScope` no longer treats an empty scope array as
  granting `admin`, and has tests pinning that asymmetry.
- Skill submission uses the same resolver, and auto-approval stays with the
  legacy admin key rather than following any admin's gateway key.

## Rate limits that were not limits
- The new credentials-login budget bucketed on `extractClientIp`, which reads the
  first element of X-Forwarded-For — a value the caller writes, since Cloud Run
  appends rather than replaces. One host could rotate the header and mint a fresh
  8-try budget per request. Now keyed on the account alone, which an attacker
  cannot rotate.
- /api/auth/forgot had the same hole, making it an open mail bomber pointed at
  any address. Now 5/hour per target address.
- POST /api/skills/[id]/view was an unauthenticated write with no ceiling.

## Money
- **Streaming charges were issued after `controller.close()`** — that is, after
  the response ends — which is the fire-and-forget hazard the rest of this branch
  removed. Both streaming paths now settle before closing.
- **A call whose upstream body did not parse was billed as a success.** The
  `.catch(() => null)` swallowed the failure, so status stayed 200 and errorCode
  stayed null. The worst case is the 30s timeout landing mid-body-read: the caller
  got HTTP 200 with empty content and paid for it, and a retrying client drained
  its balance. Now a 502 with UPSTREAM_TIMEOUT / UPSTREAM_BAD_RESPONSE, billed
  nothing, on both the OpenAI shim and the A2A route.
- The A2A stream billed a stream whose entire content was a JSON-RPC error, which
  the shipped docs say is never charged. The relayed bytes are now observed (not
  altered) so the failure is recorded and not billed.

## Privacy
- GET /api/skills/[id] served the moderator's `reviewNote` to anonymous callers
  *and* sent it with a CDN-cacheable header, so one request could publish it to
  everyone after. Now owner/admin only, on the uncached branch.
- The view dedupe key fell back to a hardcoded salt when TAKO_IP_SALT was unset —
  making the digest reversible by anyone with the source — and accepted the "anon"
  sentinel, which collapsed every such visitor into one key so only the first view
  per skill per day counted. Both now skip the dedupe row and count the view.

## Correctness
- /api/admin/stats returned `pendingAgents` as a count while the dashboard read it
  as rows, so the pending-agent review queue silently never rendered.
- The 14-day usage series ended yesterday, so the chart never showed the calls a
  developer had just made.
- SkillEvent dedupe rows were written on every view and never swept;
  `pruneSkillViewEvents` had no caller. Wired into the retention cron, with the
  index the sweep needs.
- Registration mailed a "Confirm email" button pointing at /api/auth/verify, which
  did not exist. It does now.
- Auth pages were wrapped entirely in `<Suspense fallback={null}>`, so the
  prerendered HTML was an empty <main>. Split into a server page that awaits
  `connection()` plus a client form, per the Next 16 docs.
- `noValidate` on the four auth forms turned a too-short password into "your reset
  link is invalid".

## Accessibility and i18n
- Italian `Docs.mcpInstallNote`: an ICU apostrophe escape swallowed the <link> tag
  and printed raw markup.
- The quickstart tab labels were English prose baked into a lib; they are keys now,
  translated in all 15 catalogs.
- Two <SiteSearch> instances render at once and collided on the same DOM ids.
- CodeTabs' code panel is direction-locked and keyboard-scrollable, matching /docs.
- Directional arrows mirror in RTL; the last physical `ml-auto` is gone.
- Profile tabs are links, so they carry `aria-current`, not `role="tab"` with no
  tabpanel; the health pill shows words rather than the raw enum.

172 tests (up from 158), tsc, eslint, i18n, secrets, skill-assets and the
production build all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arrows point along the reading direction. Five were still fixed left-to-right, so
under dir=rtl the Arabic locale showed 'back' pointing forward and 'browse all'
pointing backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis

oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review, and what it found

Ran the branch through an 8-dimension review (compile, i18n, security, Next 16 semantics, data/migrations, billing, UX/a11y, cross-track consistency), with 3 independent skeptics per finding, each prompted to refute rather than confirm. 137 agents, 43 findings raised, 32 survived verification — 11 were refuted and dropped.

All 32 are fixed in a25383d7 and 69488e16. The four that mattered most, all of them regressions introduced by this branch rather than pre-existing:

A gateway key could act as an admin credential. Routing the admin gate through the shared resolveApiUser meant any tako_live_… key belonging to a user with role=admin reached /api/admin/* — role changes included. Those keys are designed to be handed out: the docs tell users to paste one into an MCP client's config. Fixed, with tests pinning the rule that an empty scope array never grants admin.

The login rate limit was not a rate limit. It bucketed on extractClientIp, which reads the first element of X-Forwarded-For — a value the caller writes, because Cloud Run appends to that header rather than replacing it. One host could rotate it and mint a fresh 8-try budget per request. /api/auth/forgot had the same hole, which made it an open mail bomber. Both are now keyed on something the attacker does not control.

Streaming charges were issued after the response ended. await settle(...) sat after controller.close() — the exact fire-and-forget hazard the rest of this branch removed, and on a CPU-throttled Cloud Run instance it loses the charge silently.

A call whose upstream body did not parse was billed as a success. The .catch(() => null) swallowed the failure, so status stayed 200 and errorCode stayed null. The worst case is the 30-second timeout landing mid-body-read: the caller got HTTP 200 with empty content and paid for it, and a retrying client drained its balance. This directly contradicted the billing promise shipped in the same branch.

All 32 confirmed findings
Severity File Finding
high src/lib/admin.ts Admin API now accepts gateway (tako_live_) API keys — an admin's invocation key becomes a full admin credential
high src/lib/auth.ts New credentials-login brute-force limiter is keyed on the caller-controlled X-Forwarded-For header
high src/app/v1/chat/completions/route.ts Streaming debit is issued after controller.close(), i.e. after the response is finished — the exact fire-and-forget hazard this PR removed everywhere else
high src/app/v1/chat/completions/route.ts A call whose upstream body is unparseable or aborted by the timeout is charged full price and answered with an empty 200
medium src/app/api/admin/stats/route.ts /api/admin/stats sends pendingAgents as a number but the dashboard expects an array — the pending-agents review queue never renders
medium messages/it.json Italian Docs.mcpInstallNote: an ICU apostrophe escape swallows the tag, printing raw markup on the /it/docs page
medium src/app/api/auth/forgot/route.ts Password-reset mail budget is bypassable by rotating X-Forwarded-For, so /api/auth/forgot is an open mail bomber
medium src/app/api/skills/[id]/view/route.ts POST /api/skills/[id]/view is an unauthenticated write with no rate limit and a caller-controlled dedupe key
medium src/app/[locale]/auth/signin/page.tsx Auth pages now prerender an empty : whole page wrapped in Suspense with fallback={null}
medium src/app/api/skills/[id]/route.ts Public skill-detail API still returns the moderator's reviewNote — and now caches it at the CDN
medium src/app/api/usage/route.ts /api/usage daily series silently drops today — the chart is always one day stale
medium src/lib/views.ts SkillEvent view-dedupe rows are written on every page view but never swept — the sweep has no caller and no index
medium src/app/v1/agents/[slug]/stream/route.ts /v1/agents/{slug}/stream charges for a stream whose only content is a JSON-RPC error, which the shipped docs promise is never charged
medium src/app/[locale]/auth/reset/page.tsx noValidate on the reset form turns a too-short password into "your reset link is invalid"
medium src/components/ui/CodeTabs.tsx CodeTabs code panel is not direction-locked or keyboard-scrollable, unlike its sibling in /docs
medium src/app/[locale]/auth/signup/page.tsx noValidate added to all four auth forms leaves the untranslated string "Validation failed" as the only feedback
medium src/lib/views.ts Two implementations of the salted client-IP hash disagree: views.ts falls back to a public constant salt and to the "anon" sentinel
medium src/app/[locale]/admin/page.tsx /api/admin/stats returns pendingAgents as a count; the admin dashboard reads it as an array, so the pending-agent review queue never renders
medium src/lib/password-reset.ts Every new signup is mailed a "Confirm email" button pointing at /api/auth/verify, which does not exist
medium src/lib/views.ts pruneSkillViewEvents() is written and tested but nothing calls it; the retention cron only sweeps RequestLog
low src/lib/samples.ts Home-page quickstart tab labels are hardcoded English and render untranslated in all 15 locales
low src/components/Engagement.tsx Engagement.tsx uses physical ml-auto, misplacing the bookmark button in Arabic
low src/app/api/skills/[id]/route.ts Public GET /api/skills/[id] returns the moderator's reviewNote and submitterId, and now caches it at the CDN
low src/app/api/usage/route.ts The 14-day usage series ends yesterday, so today's calls and spend never appear on the chart that claims to show them
low src/components/ui/SiteSearch.tsx Header renders two <SiteSearch /> instances that collide on the same DOM ids
low src/app/[locale]/page.tsx Directional arrows on the home page and skill detail page are not mirrored for RTL
low src/components/ui/CategoryBadge.tsx Category chip count is still under WCAG AA after the contrast pass (4.39:1)
low src/components/Engagement.tsx ml-auto is the only physical-direction utility left in the codebase and breaks the RTL layout
low src/app/[locale]/profile/page.tsx Profile tabs declare role="tab" without any tabpanel, overriding their link semantics
low src/app/api/skills/submit/route.ts /api/skills/submit was never migrated to the shared resolveApiUser, so the two submit endpoints accept different credentials and it skips cache invalidation
low src/lib/docs-content.ts API docs advertise healthStatus: "healthy", a value the codebase never writes
low src/app/[locale]/profile/page.tsx Profile page prints the raw healthStatus enum while every other surface renders a translated label
11 findings refuted by the verification pass

Kept here because "we looked and it was fine" is worth recording: a type-check regression in notify-listed-repos.ts (does not exist), eight orphaned message keys (they are referenced dynamically), hasScope being dead code (it is the admin gate now), the ?q= cache key being unbounded (it is length-capped), migration 014's btree indexes missing from schema.prisma (deliberate — Prisma cannot represent the GIN ones either), a dropped final SSE event, three a11y claims, /api/user/agents being unreferenced, and two /api/admin/stats queries rendering nowhere.

172 tests now (up from 158, and from 0 on main), with new coverage for the authorization asymmetry and the view-dedupe paths. tsc, eslint, i18n, secrets, skill-assets and the production build all pass.

oratis added a commit that referenced this pull request Sep 7, 2026
Both documents describe the state at 2026-09-05, and #54 has since implemented
most of them. Without this header a reader meeting these files on main would take
fixed problems for open ones — and would miss which two items code cannot close
(the leaked database password and the Apple key expiry).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ass, stream billing

A 10-dimension review of this branch (3 independent skeptics per finding, each
prompted to refute rather than confirm) raised 36 findings and confirmed 20.
These are the ones that bite in production; the rest were refuted or are notes.

**The sync job could not start.** `daily-sync.ts` picked up an import of
`../src/lib/ingestion-log` in 516144d, but `Dockerfile.sync` was never touched
and copies only package*.json, prisma/ and the one script — so `npx tsx
scripts/daily-sync.ts` died at MODULE_NOT_FOUND before main(). CI never builds
that image, so nothing caught it. The irony is that the import exists to make the
two-month silent ingestion outage impossible; shipping it this way would have
frozen the catalogue again on the first nightly run.

**An admin's gateway key could publish agents unreviewed.** Routing
/api/agents/submit through `resolveApiUser` widened `via !== "session"` to include
`via === "apikey"` — a `tako_live_…` key that /docs tells users to paste into MCP
clients and CI. A leaked one could write an APPROVED Agent row with its own
endpoint and price straight into the public catalog. The skills route had already
been tightened; the two hand-written copies of the predicate had drifted, so both
now call one `canBypassModeration()` with a test that fails if it is loosened.

**Streaming charged for non-answers.** Neither streaming route checked the
upstream content type, so an agent that does not implement `message/stream` and
replies with a plain JSON-RPC body was relayed as a completed stream and billed in
full — no `data:` lines means `frameIsRpcError`/`parseEvent` never run. Both routes
now require `text/event-stream`, and a relay that ends without a single parseable
event settles as NO_STREAM_CONTENT instead of as a success. The non-streaming shim
had the matching hole: any parseable JSON without `result` was scored as a success,
and an upstream 4xx/5xx was relayed to the caller as HTTP 200 with empty content.

**Every response was a 200.** `[locale]/loading.tsx` put a Suspense boundary above
every page in the subtree, and per Next 16.2's loading.md the status cannot change
once the body streams — so `notFound()` returned 200 on all seven pages that call
it, and a server error returned 200 too. With the database down the site would have
reported itself healthy to every uptime check. Removed.

**The secret scanner was blind to its own founding incident.** check-secrets
exempts any line matching `@localhost/`, which is exactly the shape of the leaked
Cloud SQL URL (`…@localhost/takoapi?host=/cloudsql/…`). Re-committing that password
passed the scan. The exemption no longer applies when a unix-socket host override
is present.

**Anyone could lock any account out.** The login budget was per-account and spent
before the password check, so 9 requests per 15 minutes against a known address
held the owner out — indistinguishably from a wrong password, with /api/auth/forgot
budgeted the same way. The budget now gates guessing, not the owner: over budget the
password is still verified and a correct one still admitted; a separate, much wider
ceiling bounds the bcrypt cost of a flood.

Also: `{ id: "asc" }` tiebreakers on /skills and /api/skills, which were the only
paginated listings left without one (rows repeat and vanish across pages);
`Object.hasOwn` on two searchParams-driven lookups, where `?topup=__proto__` took
the dashboard into the error boundary; and `aria-current` on the /skills filter
chips, which signalled the applied filter by colour alone.

tsc, eslint, vitest (176), check:i18n, check:secrets, check:skill-assets and
next build all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit 8cdf486 into main Sep 7, 2026
1 check passed
oratis added a commit that referenced this pull request Sep 7, 2026
…-f13b3f

UX + technical review documents (docs/07, docs/08) with phased optimization plans. The implementation landed in #54.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant