Repository navigation
Conversation
|
@MisonL is attempting to deploy a commit to the Crafter Station Team on Vercel. A member of the Team first needs to authorize it. |
Two defects found while reviewing the deployed app. **Every install failed for pets stored under a legacy bucket host.** `resolveInstallablePet` checked the URL as stored against `R2_TRUSTED_HOSTS`, which deliberately excludes the hosts the bucket used to serve from, and only then rewrote it to the canonical host. So the check rejected rows the rewrite would have made valid — 0 of 26 approved pets resolved on a database seeded from production, and `/api/install-pet/<slug>` answered 404 for all of them. The rewrite now runs first and the rewritten value is what gets validated. The security property is unchanged, and depends on that order: the value checked is the value served, and `toCurrentR2PublicUrl` returns its input untouched for a host it does not recognize, so an attacker-controlled URL still fails the check rather than being laundered into the allowlist. The test drives `resolveInstallablePet` itself and fails if the two statements are swapped back — asserting on the helpers alone did not, which is how the first version of this test passed against the bug. **An out-of-range cursor returned 500 with the whole statement in the logs.** `cursor` becomes the SQL `OFFSET` unclamped, so `?cursor=99999999999999999999` reached Postgres as `1e20` and came back `22003 bigint out of range`, for a request that only asked to page past the end. It is now clamped to 1e9, which keeps the existing answer for every real dataset — an offset past the end is an empty page — and stays exact when a limit is added, so `nextCursor` still matches the offset the next request sends. Verified against Postgres: 26/26 pets resolve, and the oversized cursor returns an empty page with a 200. The bound lives in `pet-search-cursor.ts` rather than `pet-search.ts` because the latter opens a database client at import time, which would make a test of arithmetic require a configured database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`POST /api/pet-requests` inserted the request row and the creator's own vote
as separate statements. `pet_request_votes` carries no foreign key, so a vote
insert that failed left the request behind with nobody attached to it: it read
the column default of 1, and the next upvote on the same text recounted it to
0. Both writes are plain SQL, so one transaction covers them.
The embedding update stays outside, deliberately — it is best-effort by design
(`.catch(() => {})`) and a failure there must not roll the request back.
The test drives the route against a PGlite database standing in for
`@/lib/db/client`, with a trigger that refuses the creator's vote. That
failure is what makes it discriminating: with both writes succeeding, the
transactional and non-transactional versions are indistinguishable, and the
first version of this test passed against the bug for exactly that reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`publicTrafficGuardKey` reads `x-real-ip` and `x-forwarded-for` and keys the
limiter on the result. Measured locally, rotating `x-forwarded-for` hands each
request a fresh quota — 40 of 40 through, where a fixed value gets exactly the
60-per-hour ceiling — so the question is whether that is a defect.
It is not, on the deployment this app targets. Vercel overwrites
`x-forwarded-for` on every request and does not forward a client-supplied
value ("This restriction is in place to prevent IP spoofing"), and documents
`x-real-ip` as carrying the same value. Off Vercel the headers arrive as sent,
but that is a property of running the app directly rather than something to
paper over here: a limiter that ignored them would have no client identity to
key on. Anything putting this app behind a different proxy has to make that
proxy set them the way Vercel does.
Comment only — no behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every locale is supposed to read in its own language, but a set of
user-visible strings were still English on /es and /zh. They were not
hard to find once looked for — they were simply in files nobody had
swept, and a scan that only reads a diff's added lines never sees them.
The gallery card and the footer bar under it are the bulk: the sort
options came from a module-level `SORT_LABELS` map, the filter group
headings were prop literals, and eight strings sat as bare JSX text
nodes (`Filters`, `Load more`, `End of gallery · {total} shown`). The
action buttons announced `Like <name>` / `Copy install for <name>` and
the sprite carried `<name> animated`, so a screen reader on /es or /zh
read English even where the visible copy was translated.
Two strings never reached the page's language at all: the batch label
was built with a hardcoded `en-US` inside a locale-agnostic cached
aggregate ("Class of October 2025"), and the request-error path
preferred the route's English `message` over the translated fallback.
Page metadata was localized in the body but not in `generateMetadata`
for six pages, so /zh/download served a Chinese page under an English
title, and `og:url`/`twitter:` followed.
Also fixes the checker's own blind spots: `i18n-check` compared keys
but not argument names or value kinds, so a renamed placeholder or a
leaf-turned-container passed. The comparison now lives in
`src/lib/i18n-message-compare.ts` with its own tests, and the pre-commit
hook runs the parser whenever a message file is staged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pets whose spritesheet or pet.json is still stored under a retired public host — `pub-…r2.dev`, the old workers.dev host — resolved to nothing. Their URL is deliberately absent from the asset allowlist, and the allowlist check ran *before* the rewrite that would have made the URL valid, so every such pet answered "unsupported_source": no thumbnail, no preview, no sticker, no install script. `toCurrentR2PublicUrl` is a no-op for a host it does not recognize, so moving it ahead of `isAllowedAssetUrl` cannot admit an attacker's URL — the value that gets validated is now the value that gets fetched, which is the property that was missing. Applied to the three call sites that had it backwards: `install-script`, `pet-public-artifacts`, and `similarity`. The `u/[handle]` OG image route read the raw `spritesheetUrl` column while every sibling route read the already-rewritten `spritesheetPath`, so a legacy-host pet silently vanished from that collage. The CLI mirrored the server's allowlist as a set, which no test ever compared for *matching logic* — it read `hostname` where the server reads `host`, so a non-default port was trusted by one and refused by the other. Aligned, and the install path now validates the slug before joining it into `~/.petdex/pets/<slug>`: `path.join` resolved `../../…` outside the pets directory. Also reads the sprite extension off the parsed pathname, so a URL with a query (`sprite.png?v=2`) no longer lands PNG bytes in `spritesheet.webp`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`POST /api/pet-requests` read `pet_requests.normalized` outside any transaction, then branched: two requests for the same text could each miss the other's row and both insert. They also wrote two rows — the request and the creator's own vote — as separate statements, so a vote insert that failed left the request behind, reading the column default of 1 until the next upvote recounted it to 0. Both writes now share one transaction, serialized on `pg_advisory_xact_lock(hashtextextended(normalized, 0))`. The four statements stay separate rather than collapsing into one CTE: every CTE in a statement shares the snapshot taken when that statement began, so a read placed after the lock still cannot see the row the lock was protecting. `executeAtomicReturning` replaces `executeAtomic` and returns each statement's rows; it uses `db.batch` where the driver has it (Neon HTTP, which is production and whose `transaction()` throws) and a callback transaction elsewhere. Separately, several responses said nothing about caching, which is the one case a shared cache fills in for itself. The 401/429/500/200 branches of pet-requests, the empty-pool JSON 404 of pets/random, and both branches of notifications now carry `private, no-store`; the public pet search keeps its `public` header. Also bounds `?limit` to a positive integer — `0.5` made Postgres reject the query and the route answer 500, while `abc` and `-1` were read as "no limit" and skipped the 80-row cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`www.petdex.dev` was in neither redirect layer, so it answered 200 with the whole site while every canonical, hreflang, `og:url`, and sitemap entry on the page is absolute on `petdex.dev` — a render that advertises an origin other than the one that served it. Added to both `next.config.ts`'s host matchers and the proxy's set, and both sides now admit the trailing root dot (`www.petdex.dev.`), which resolves to the same address and was the spelling that slipped through. With `localePrefix: "as-needed"`, next-intl answers an unprefixed default-locale path by rewriting `/download` to `/en/download`, and Next re-runs the proxy on that target — where next-intl sees the prefix it just added, strips it, and answers 307 to `/download` forever. Every unprefixed route looped locally; only `/api` and prefixed locales worked. The rewrite now carries a per-process marker the proxy checks on re-entry, and stands down only for a path that already carries a locale. The 404 for an unmatched path is now routed back into the locale segment by a catch-all, so it renders the branded localized page instead of Next's built-in English one. Segment `not-found.tsx` files re-export `generateMetadata` as well as the component — re-exporting only `default` dropped the 404's own metadata and let the root layout's `index, follow` contradict the `noindex` Next injects. The redirect target is built by assigning `pathname` onto a URL from the canonical origin rather than `new URL(path, origin)`, which resolves a protocol-relative path and would make the redirect an open redirect if Next ever stopped normalizing a leading `//`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The origin check accepted any `host.endsWith(".vercel.app")`. That is a
public suffix — anyone can deploy under it — so `https://evil.vercel.app`
passed and could POST to every `requireSameOrigin` endpoint with the
visitor's Clerk cookie, which is the cross-site write the module exists
to stop. Vercel sets the deployment's real host in `VERCEL_URL` and its
siblings, so the exact hosts are known and a suffix match was never
needed.
`/sitemap.xml` reads facet counts to decide which vibes and kinds are
worth listing; reaching them through the Upstash-backed `loadFacets` made
the route render dynamically and moved `<lastmod>` to request time. It
uses the Redis-free accessor now, so the element stays the page's last
content change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`app-ci.yml` ran `bun test --timeout 60000` while the root script carried its own raise, so the two could drift — raising the script for a new spec would not have reached CI. It now runs `bun run test`, and adds the `bun run check` and `bun run i18n:check` steps the branch relies on. `cli-ci.yml` gets the same `bun run test` for the same reason: the suite timeout lives in the package script. The pre-commit hook parses the message files whenever one is staged. Biome cannot judge them — a key that parses as a rich-text tag is valid JSON, correctly formatted, and renders as its own name — and the hook's self-test asserts both that it runs then and that an ordinary TypeScript commit does not pay for it. AGENTS.md records the new invariants: `executeAtomicReturning` and why its statements stay separate, and that `tsc --noEmit` runs in CI even though nothing local invokes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo had no way to run the app in a container. `docker-compose.dev.yml` brings up Postgres and Redis for `bun dev` on the host, and nothing built the app itself. `Dockerfile` is four stages: `deps` installs from the lockfile, `source` is that tree plus the code, `builder` compiles the Next app, `runner` carries only what the server reads. The runner copies `public/`, `.next/standalone`, and `.next/static` — the whole install is 928M, and the runtime touches a fraction of it. `output: "standalone"` is what produces that traced tree, and it is gated on `PETDEX_STANDALONE` rather than set unconditionally. The same config also builds the Vercel deployment and the setting's effect there is not documented either way, so its build stays byte-identical and only the Dockerfile opts in. The build brings its own Postgres. `next build` imports every route module to collect page data, and two throw at module scope without env — the DB client on DATABASE_URL, the telemetry route's rate-limit secret in production. Worse, `/[locale]` and `/[locale]/pets/[slug]` are `force-static` with `generateStaticParams`, so collection is a real query and a placeholder URL fails on `ECONNREFUSED`. A throwaway cluster is initialized, pushed, and seeded inside the builder stage, so a fresh clone plus Docker is the whole requirement, and the prerendered HTML describes the same rows the running app reads. `docker-compose.deploy.yml` runs the app with Postgres, Redis, and the Upstash REST shim, all self-contained: auth goes through the in-process Clerk mocks, so no external credentials are needed to boot a site with data. The `migrate` service pushes the schema and seeds before the app starts, and targets the `source` stage — pointing it at `builder` made BuildKit build that stage twice, once per set of build args, for a container that never serves a page. Verified by running the stack: `migrate` exits 0, the home page renders 26 seeded pets on the first response, the three locales show their own gallery strings, `www.petdex.dev.` answers 308, `/u/<unknown>` is a 404 with no `index, follow`, and the data survives a `down`/`up`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auditing the deployment turned up three env vars passed as Docker build args that nothing reads while the bundle is compiled. `PETDEX_URL` is read at request time by `src/proxy.ts` and by `/api/pets/random`, and neither site is `NEXT_PUBLIC_`, so the value is never inlined. Verified in the built artifact: `process.env.PETDEX_URL` survives in the proxy chunk and the value this PR was passing appears zero times. `PETDEX_MOCK_AUTH` is the opposite — `next.config.ts` reads it to swap the Clerk packages, and the compiled output contains the mocks — so that one stays a build arg. `R2_PUBLIC_BASE: http://127.0.0.1:9` was inert. `normalizeBase` in `src/lib/r2-public-url.ts` rejects any scheme that is not https and falls back to `https://assets.petdex.dev`, which is where the seeded sprites live. The comment claimed it pointed writes at an unreachable host; the R2 credentials are what do that, and the public base did nothing at all. `migrate` carried `UPSTASH_*` and `R2_*` because the app needs them, but `seed-dev.ts` imports only the DB client and the mock module. Confirmed by running it against a bare Postgres with none of those set: it seeds normally. Its `redis-rest` dependency went with them, and `app` now waits on the shim itself so it still cannot race it. `serverExternalPackages` listed PGlite only in mock mode, but `src/lib/db/client.ts` imports `../mock/db` at module scope regardless, and that module imports PGlite the same way — so every build inlined it. Turbopack then emitted the 17 MB wasm/data pair into `.next/static/media`, where it was served publicly even though no client chunk references it (zero client references, two server ones). Marking it external moves it into the traced `node_modules`, which standalone needs and Vercel installs from `dependencies`. The same path is taken by the production build, so this is a pre-existing leak this closes rather than one it introduces; the image drops from 480 MB to 443 MB and that URL now 404s. Also: `restart: unless-stopped` on the long-running services, matching `docker-compose.dev.yml` — without it a daemon restart left the stack down. `.dockerignore` now excludes `.env.dev` and `.env.mock`, which its own comment already claimed to do. And the build-time seed comment no longer claims it prerenders pet pages: `seed-dev.ts` never sets `featured`, so `getStaticPetSlugs` returns nothing and those render on demand. It is what makes the home gallery non-empty, nothing more. Verified on a cold build with an empty cache: `next build` runs once (the `source` stage split means `migrate` no longer forces a second), `migrate` exits 0, the first response renders 26 seeded pets, the three locales show their own gallery strings, `www.petdex.dev.` answers 308, `/u/<unknown>` is a 404 with no `index, follow`, and the data survives `down`/`up`. 916 root tests, Biome, `tsc --noEmit`, and `i18n:check` pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The seed is not an upsert. `scripts/seed-dev.ts` looks the slug up and `continue`s when it is there, so a row it already wrote is never corrected — verified by setting a seeded `display_name` to something else and re-running: the edit survived. Re-running is still safe, which is what the comment claimed, but "upserts by slug" said the opposite about what happens to an existing row. `drizzle-kit push` reconciles by diffing and, on a destructive change, emits the DROP and proceeds. `--force` skips the confirmation prompt, not the statement. The repo does this everywhere (`dev:docker` included) and there are no migration files to apply, so the command is right — but this file is more likely than `docker-compose.dev.yml` to be pointed at something whose contents matter, and the comment now says so. The app had no healthcheck while every infrastructure service did, so `docker compose ps` could not tell a serving container from a wedged one. The probe uses bun's fetch because the runner image carries no curl or wget, and it reads `/version.json` — a static file written at build time — so it does not report the app unhealthy whenever Postgres is merely slow. Verified it reports healthy on a fresh build and that the probe itself exits 1 against a port nothing is listening on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pass A review of the Docker deployment turned up defects the build itself did not catch, plus the half of the metadata work the branch's own commit message claimed but did not do. Deployment - `same-origin.ts` allowed only petdex.dev and localhost, so every state-changing POST from the deployment's own origin (a browser sends `Origin: http://127.0.0.1:3100`) was rejected — likes, submits, votes, uploads, 26 routes. It now also accepts the host `PETDEX_URL` names, the same value `src/proxy.ts` already reads. - `/api/manifest/full` built its install command from `new URL(req.url)` and advertised the container bind address: `curl -sSf http://0.0.0.0:3000/install/<slug> | sh`. Extracted the origin resolution `/api/pets/random` already had into `src/lib/public-origin.ts` and used it in both. - `.dockerignore` listed individual `.env.*` names and missed bare `.env`/`.env.production`, so a maintainer's gitignored production env rode `COPY . .` into the `source` stage and was loaded by `next build`. Now `.env*` with `!.env.example`. - `seed:dev` was missing `--conditions react-server`, so the script it names could not run on its own. Metadata localization - `pets/[slug]`, `u/[handle]`, and `collections/[slug]` localized only their `notFound()` branch; the success path stayed English template strings, so `/es/pets/boba` rendered a Spanish page titled "Boba: Animated Codex pet". Both paths read messages now. - `community/page.tsx` returned a hardcoded `title: "Not found"` when its invite URL was unset — which is the self-hosted deployment. Private responses - `my-pets/claim` (which returns the caller's email and GitHub URL), `my-pets/approved`, and `feedback/[id]/replies` answered without a `Cache-Control`, on the same reasoning the branch gave for `/api/pet-requests`. Tests and guards - `page-metadata.test.ts` matched only double-quoted literals and cut each `generateMetadata` body at the first `\n}` — the close of a multi-line parameter list — so 19 of 25 pages were never scanned. Both fixed; the widened guard is what caught the community page. - Three suites replaced `@/lib/ratelimit` wholesale, starving whichever limiter another suite in the same process had stubbed; they spread the real module now, as the branch already did elsewhere. - `SEO_SOURCE_FILES` gained the eight public indexable pages it omitted. Docs corrected to match the code: the `.env.dev` default, the `initdb` credential claim, the migrate service's restart semantics, and the healthcheck's liveness-versus-readiness boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`/stickers/<slug>` inherits `index, follow` from the layout and is linked from the header whenever the explorer is on, but nothing listed it, so the header link was the only path a crawler had. The gap was structural: the sitemap-coverage guard skips every dynamic segment, so `[collection]` could not show up in it. Listing slugs would have been worse than the omission — a collection with no pet the current sticker policy publishes calls `notFound()`, and the explorer being off makes every slug 404. So the new accessor applies both conditions itself, reusing `isStickerExplorerEnabled` and the same per-pet predicates `getStickerCollection` uses, and the sitemap lists exactly what the page can serve. The vibe and kind entries already work this way. It stays off the Redis path for the same reason `loadFacetsForSitemap` exists: a Redis call in the sitemap's graph makes Next render the route dynamically and moves `<lastmod>` to request time. The build still reports `/sitemap.xml` as a static `○`. Also: - The coverage guard now walks dynamic families and requires each to be listed or excused, with the excuse checked against the page rather than trusted. It records `/u/[handle]` as deliberately unlisted — indexable, absent, and a product decision about which profiles qualify rather than something to fix here. - `sticker-sitemap.test.ts` drives the accessor against PGlite, covering the explorer gate, the kill switch, each eligibility predicate, the owner-scoped filter, `updatedAt` passthrough, and the one-entry-per- collection collapse. Dropping any of those four filters fails it. - `.env.example` documents the three sticker variables, which nothing documented; the demo flag drives the page and header but not the sitemap, and the comment says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dynamic-family guard requires each family to be listed or excused; this makes the profile excuse a decision with a reason rather than a placeholder. The page is indexable and reachable, but which profiles belong in a search index — every handle, or only those with approved pets — is the product's call, and the guard's own EXCUSED set already treats `u` as not-for-indexing. Flipping it later is a new accessor plus one word here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both route walks in the coverage guard stepped a fixed number of levels: the static one covered the top level plus one nested level, and the dynamic-family walk I added did the same. A `[a]/[b]/page.tsx` three levels down was invisible to the check whose whole job is noticing a route family nobody accounted for — the same structural blind spot that let `/stickers/[collection]` go unlisted in the first place. Both are now recursive. Verified by planting a `[a]/[b]/page.tsx` and a `zzstatic/a/b/page.tsx`: each is now reported, where the previous version passed. Nothing in the tree is three deep today, so this is future proofing rather than a live miss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects an audit turned up, one of them mine from the previous round. The CSRF allowlist compared `PETDEX_URL`'s host exactly, so a deployment configured as `http://127.0.0.1:3100` rejected a browser that reached it as `localhost` or `[::1]` — which is what the browser autocompletes to, and `public-origin.ts` already treats the three as one server. Verified live before the fix: `Origin: http://localhost:3100` → 403 on every guarded write, `127.0.0.1` → 200. The comparison is now origin-to-origin, so the scheme has to match as well: an `http://` deployment no longer accepts an `https://` origin of the same host. Aliases are granted at the configured port only, so this is not an allowlist for the whole loopback interface. The metadata guard matched one line at a time, which a value written on the next line walks straight past — `title:\n "English"` passed. It now walks value expressions: multi-line aware, string-aware, and following one level of identifier indirection, so a constant defined outside `generateMetadata` is caught too. Verified by planting each bypass — multi-line, concatenation, and an outside constant all fail now, where the previous version passed. Fixture coverage pins the shapes so the scan cannot quietly narrow back to the easy ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comments and the test header said `/sitemap.xml` revalidates on the 86400 the route declares and freezes `lastmod` at build time. It does not: Next takes the lowest revalidate across a route's fetches, and `computeFacets` is cached at 300 for the search pages, so the effective period is 300s and `lastmod` moves each regeneration. The build's own route table has said `5m` since this branch's first build. This predates the branch — `main` has the same 300s aggregate — and the branch still improved it from "recomputed on every request" to "static, regenerated every 300s". Only the description was wrong. Raising the period means giving the sitemap its own longer-lived entry, separate from the clock the search page reads, which is a change of its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`takedownPage`, `unsubscribePage`, `telemetryPage`, and `docsPage` carried their English `metadata.title` in es.json — `Takedown`, `Unsubscribe`, `Telemetry`, `Docs` — so a Spanish visitor's browser tab and unfurl were English while the page under them was Spanish. That is the same defect the branch fixed for `/pets/[slug]`, `/u/[handle]`, and `/collections/[slug]`, and zh translated all five. Each translation follows the page's own Spanish body (`Reportar una mascota`, `Cancelar suscripción` in the form) rather than a literal rendering, and `docsPage.metadata.ogTitle` moves with the title. The description values were already Spanish. The rest of the es translation debt — around 40 pre-existing strings, none of them metadata — is left alone; it predates this branch and is not part of its subject. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`resolveInstallablePet(slug, origin)` never read its second argument — it was declared `_origin` — and both callers computed it as `new URL(req.url).origin`. In the container that is the bind address, so the value sitting in the parameter was `http://0.0.0.0:3000`: exactly the string this branch removed from `/api/manifest/full`, parked one call away from the install script, where the next person to use it would have emitted it into a `curl … | sh` line. The parameter is gone, along with the two computations feeding it. The script's own URLs stay the canonical `https://petdex.dev` literals in `install-script-render.ts`; a deployment-specific origin, if one is ever wanted there, belongs to `publicOrigin(req)`. Pre-existing (main has the same dead parameter), not a behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An audit of `process.env.*` against `.env.example` turned up six the code reads and the example file never mentions. Each is inert when unset, which is exactly why it went unnoticed — but two of them change behaviour rather than disabling a feature: - `RESEND_WEBHOOK_SECRET` — unset, `/api/webhooks/resend` answers 500 on every delivery, so bounce and complaint events never land. - `CLERK_CLI_CLIENT_ID` / `CLERK_CLI_SCOPES` — unset, `/api/cli/auth-config` serves petdex.dev's OAuth client, so a self-hosted instance's `petdex login` sends its users to the production Clerk tenant. `AI_API_KEY` (or `OPENAI_API_KEY`) and `EDIT_AI_MODERATION_ENABLED` gate the optional moderation checks in `edit-policy.ts` and the WeChat QR validation, and default to the no-moderation path. All six predate this branch; only the sticker variables in the previous commit were new. Documented here because the compose deployment is meant to be self-contained and these are the ones that quietly point elsewhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An audit using "zh translated it, es did not" as the signal for
"this is user-facing prose" found 107 Spanish keys still carrying the
English value. Spanish readers got an English docs page: every nav
section, all four failure-mode rows, the validation rules, the kind and
vibe names on /kind and /vibe, the telemetry table headers.
All 107 are translated, using each page's existing Spanish prose for
terminology — `mascota`, `colección`, `vibra`, and the verb forms the
pet and submit pages already use — rather than literal renderings. Rich
text keeps its tags and its placeholders (`{petName}`, `<slug>`, the
`petdex submit …` command samples) so the docs code blocks stay
runnable. Five Chinese keys came out of the same audit.
Two fragment keys needed care, since the sentence is assembled from
three pieces in JSX: `takedownPage.nonIpBefore` already ends with
"escríbenos por", so `nonIpAfter` stays the sentence-final `.` rather
than repeating the phrase — the first pass produced "…escríbenos por
[correo] escríbenos.".
Brand and technical strings are deliberately left alone: `Petdex`,
`Codex`, `GitHub`, `CLI`, `PNG`/`SVG`, `macOS`/`Linux`/`Windows`,
`Spritesheet`, `Error 404`, the `<slug>`-style placeholders, and the
settings path are identical across all three locales on purpose.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`/es/kind/creature` and `/es/vibe/cozy` were readable Spanish wrapped
around an English word: "Mascotas cozy para agentes de código", "Mascotas
animadas cozy en pixel art". A previous pass had translated the `label`
the eyebrow uses and left `title` and `metaDescription` — the page's
`<title>` and its search-result description — carrying the English slug.
Both are now translated, in the feminine plural the noun "mascotas"
requires ("acogedoras", not "acogedores"), which is why the earlier
`label` values are singular masculine: they sit next to a category name,
not a noun.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Codex theme dialog was written in English throughout while its trigger
button was already translated, so a Spanish reader tapped "Tematizar mi
Codex" and got an English dialog. Its three error paths were worse than
untranslated: they rendered whatever the API sent, which is the internal
code (`not_found`, `no_color`) or the server's own English sentence
("Pet has no extracted dominant color yet."). The status codes are now
mapped to messages, and an unrecognized one degrades to a single generic
message carrying the code.
The claim banner on /u/<handle> had the same shape — hardcoded English
plus `data.message ?? data.error` printed verbatim — so its five API
codes are mapped the same way, and `pending` / `approved` / `rejected`
stop rendering as raw enum values.
Also fixes 15 Spanish strings that dropped a mandatory accent
(`instalacion`, `revision`, `coleccion`, `guia`, `Anade`, `ultima`,
`version`, `rapido`, `documentacion`, `despues`, `Mas`, `valido`,
`Edicion`, and "Click derecho"). All 15 are pre-existing on main and
unchanged by this branch; the file already spells each of these words
correctly elsewhere, which is what makes them defects rather than style.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continues the sweep the Codex theme dialog started. These components render
on pages any signed-in user reaches, and every one of them was English-only:
- /u/<handle> tabs and the inline profile editor, including its error path,
which printed `handle_taken` through `error.replace(/_/g, " ")` — an
internal code, uppercased, in place of a sentence.
- The notifications bell panel. Its `describe()` helper built every title
from English fragments, and `relativeTime` formatted the >1w fallback with
`undefined` as the locale, so the date followed the server's locale rather
than the reader's.
- The feedback thread, which rendered the raw `status` enum and duplicated
`feedback.kinds` as a second, untranslated KIND_LABEL map. It now reads the
shared namespace, as does /my-feedback, which carried the same duplicate.
- Pet counters, the pet floater's aria labels, the collection caught
progress, and the pet/collection detail pages' remaining literals.
"Space", "Hunter" and the `{name} idle` sprite labels keep their English
spelling deliberately: the first is a physical key, the second a person's
name, and the third is a proper-noun label read by screen readers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third batch. These are the controls an owner actually presses, and each one carried its copy — and its error text — in English: - Pin/unpin on the profile grid and the collections manager, both of which rendered the API's own string on failure. - The gallery and pinned reorder grids. Both printed the route's error code through `error.replace(/_/g, " ")`; the codes are now mapped, and the pinned-reorder test that pinned the English words "Retry" and "Restore saved order" pins the message keys instead, so localizing them no longer reads as a regression. - The suggest-for-a-collection panel and the claim-request dialog, which mapped three of the route's nine codes and passed the rest through raw. - The owner edit panel, whose upload failures surfaced as "Spritesheet upload failed" and "Missing petjson slot in presign response". - The pet action menu's delete and withdraw prompts. These are `window.prompt` and `window.confirm`, so the message is the page's string: an English sentence in an es or zh session. - The `sr-only` Close label in the shared dialog and sheet primitives, which is read aloud by screen readers. The edit panel's presign path no longer forwards the server's `message` field: every code it can send is mapped, so the prose was dead weight that would have leaked English the moment the route changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sticker explorer takes every label through a `Labels` prop built from the `stickers` namespace, except the deck's remove button, which had a literal `aria-label="Remove"` — so the one string a screen reader announces for that control was the one string the server never localized. The creator badge on /u/<handle> had the same shape: its visible text came from `profile.creatorOfPetdex`, but its tooltip was an English literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me values rotation_day and last_update_check_ms in desktop-native-settings.json passed any finite float straight to @intFromFloat, so a persisted 1e300 (a bad write, or anything that can edit the user-writable file) made every startup hit undefined behavior in ReleaseFast instead of falling back to the default — a persistent boot crash from one bad field. Both now require the value to fit the target integer before the cast. session_reconcile's sqliteMillis had the same shape in its passthrough branch: the sub-1e10 branch is range-safe by construction, but a finite 1e300 from a corrupt agent journal hit the else branch unguarded. It now clamps to 0 past maxInt(i64). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fest echoes, keep 404/error pages in locale Three CLI hardening gaps, one i18n pair: PETDEX_URL redirected every API call including the Authorization header, with no scheme check — the issuer path already refuses plain http off loopback, but the base URL it authenticates against did not. The override now follows the same rule, printing a clean message and exiting instead of a module-eval stack trace (main().catch cannot catch module scope). Manifest displayName/submittedBy come from submitter profiles and are echoed straight to the terminal by list/install; a field carrying an OSC title rewrite or cursor-mode escape executed against the console. Both parsers now strip control characters (Unicode category Cc) before the text can reach a display; printable Unicode survives. The [locale] 404 page hardcoded /#gallery, /about and /submit and the error boundary hardcoded / — under localePrefix as-needed those CTAs dropped /es and /zh visitors back into English mid-page. Both now route through withLocale/useLocale like the rest of the tree, and the locale switcher preserves the URL hash the way it already preserved the query, so /docs#install switches language without losing the anchor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ake metrics answer 404 for non-approved pets The vibe path materializes LIMIT = cursor + limit rows because a cosine ranking has no seekable offset — it over-fetches and slices in JS. clampCursor alone still let a hand-set cursor of 1e9 through, so one request asked Postgres to produce a billion-row result on a private-no-store endpoint the edge cannot absorb. The offset now caps at 600: deep paging through a similarity ranking is not meaningful past a few pages, and fetchMore UI never approaches it. GET /api/pets/[slug]/like was the only fully unguarded pets subresource — anonymous, no IP limiter, no slug check, per-request COUNT(*) — while its own POST and every sibling (track-zip, metrics) cap by IP and validate the slug. It now reuses the publicCatalog limiter and the same slug shape, and its 429 carries the same uncacheable headers as the POST branch so the edge cannot pin a rate-limited answer. getMetricsForSlug matches by slug alone, so withdrawn or rejected pets kept answering 200 with live counters while /pets/<slug> and /variants answered 404 — a "404 page but live metrics" signal a slug dictionary could use to confirm a takedown. The route now verifies the pet is approved before reading counters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rrect the AI key docs The pet-request presign endpoint checks only the client-declared size; the object that actually lands in R2 is whatever the caller PUT to the signed URL, and the requests/ prefix sits outside every GC candidate shape — so an oversized upload became a permanent public object nobody reclaims. POST /api/pet-requests now heads the referenced object before the row is written (4 MB, the presign endpoint's own ceiling) and answers 422 when it cannot verify it. .env.example documented AI_GATEWAY_API_KEY as the key "embeddings and automated review use", but nothing in the repo reads it — the review checks take AI_API_KEY or OPENAI_API_KEY, so a contributor following the file applied for a credential no code ever consumes. The entry now says so, and the undocumented runtime switches (PETDEX_MOCK_AUTH with its never-on-a-reachable-host warning, PETDEX_MOCK, PETDEX_STANDALONE, PETDEX_REVIEW_DB, PETDEX_DISABLE_NEXT_CACHE) are recorded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lection rows with a slug rename gallery-order's own docstring promised "slugs not included are pushed to the end with their existing relative order", but the CASE's ELSE branch was dead — the WHERE only matched listed slugs, so unlisted pets kept their old gallery_position and sorted ahead of the new 1..n block. A never-ranked pet (position 0) jumped to the front of every partial reorder. Positions now cover the owner's whole approved gallery: listed slugs take 1..n in request order, unlisted ones follow after the prior max so their relative order survives. Pinned by a PGlite suite. Renaming a pet through the admin edit path freed the old slug without touching pet_collection_items or cover_pet_slug, which key on the slug: the pet silently vanished from every collection it belonged to, and a later submitter claiming the freed slug inherited the membership rows — a pet that was never added appearing in collections it does not belong to. The rename now migrates member rows and covers in the same breath and revalidates the covered collections' tags. The concurrency finding from this round — MAX_COLLECTION_PETS checked against a pre-lock snapshot, so two racing PATCHes can exceed the cap — needs an in-statement count guard like the owner-collection cap has; recorded for a follow-up rather than rushed here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stored slugs are lowercase and the read path lowercases too, so a mixed-case URL made the collection page work while its request action answered 404. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es transactionally, align locale formatting The vibe path's total was the length of the fetch window (LIMIT = cursor + limit + 1), so API consumers watched the match count rise as they paged; the gallery only surfaces total while filters are active and already falls back to pets.length, so vibe now omits it past the first window instead of reporting a number that grows with the cursor. The like route's denormalized recount ran as a second statement after the write: a concurrent flip's recount could read a pre-write snapshot and overwrite ours with a stale like_count that persisted until the next like. The recount now runs inside executeAtomicReturning, whose per-statement READ COMMITTED snapshots inside one transaction give the count the write's own snapshot — the same separate-statements rule every other write here follows. Two locale mismatches: my-pets' submission dates formatted with the browser locale while every sibling call site passes the site locale (a zh page showing "Oct 14, 2026"); and og:locale/og:locale:alternate were absent entirely, so OG-only scrapers (WeChat, Slack) that never read hreflang had no signal which language a shared card is in. Also one untranslated fragment: es rowFrames said "frames" where its sibling key says "fotogramas". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gate to the OG routes admin-import-pet skipped the keyword and URL blocklists every submission path runs, so one command published pre-approved content the normal flow would reject — and the keyword-sweep script that cleans up afterwards depends on the same list the import bypassed. The import now runs findBlockedKeyword and containsUrl over the imported text fields and refuses with an explanation; --skip-blocklist is the explicit escape hatch for the rare legitimate case, keeping --approve's legitimate skip of human review untouched. Four opengraph-image routes were the last request paths reaching sharp without the decode-dimension gate publish, color-extract and dhash all apply: fetchR2AssetBuffer bounds the bytes at 8MB, but a flat-color PNG inside that budget can declare ~16000x16000 (sharp's default ~268MP ceiling admits it), and these are public, cache-miss-refreshable URLs. Each route now reads metadata and answers through isDecodableSpriteSize before any decode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r sitemap tag into invalidation Data-cache tags do not reach full-route ISR entries — the revalidate endpoint's own comment says so, and it has always purged /pets/[slug] shapes for that reason. But the write paths that actually change a pet's visibility only flushed tags: an owner takedown left the force-static detail page serving deleted sprite references for its whole 24h window. The purge now lives in a shared helper (pet-page-revalidate) that takedown calls and the endpoint reuses, instead of each carrying its own copy. revalidateCollectionTags also now flushes "sticker:collections" — getStickerSitemapEntries reads under that tag and nothing else ever invalidated it, so a taken-down publication stayed advertised in sitemap.xml for a day after its page answered 404. Recorded, not patched: home and facet pages are force-static with a 24h ceiling and have no purge at all (new approvals lag; a kind's first pet sits behind a static 404 shell), and facet pages cap at 60 pets with no pagination while their count label reports the unfiltered total — both need a per-path invalidation design rather than a tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docs page still said "Petdex CLI · v0.1" (1.4.0), described install as equivalent to the curl one-liner (the CLI writes ~/.petdex/pets and ~/.codex/pets; the script writes only the latter, so the Petdex Desktop never sees the pet), walked new users into the retired `doctor` command and a `/petdex status` that never existed, promised per-command --help that is not implemented, claimed a "nine state rows" grid against the 8x9/8x11 reality, and over-promised hatch/desktop/hooks as CLI scope. The download metadata still said macOS-only while the page and CI ship macOS, Windows, and Linux — search and OG cards told Windows users there was nothing for them. All three locales corrected together; the retired-verification step now points at the folders the installer prints instead of a removed command. CLI README: repaired the broken sentence in the install-issues section and named both install directories, and fixed the stray "rebote". Recorded, not patched: the "200+ pets" vs "70+ entries" contradiction needs one decision about which number to display; the macOS-era download.* strings and the DownloadCTA component are unused; the presign429 failure row attributes the 10/24h register limit to the 20/h presign limiter; the login domain (accounts.petdex.dev vs clerk.petdex.dev) needs a live petdex login to settle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…assed caughtSlugs override The telemetry SQL bucketed days with a bare date_trunc on the timestamptz column, so the day boundary followed the DB session timezone while dex-batch and the search month buckets pin AT TIME ZONE 'UTC' — same page, two time conventions, invisible until someone points a connection at a non-UTC session. Every bucket is now pinned; observable behavior is unchanged on today's UTC sessions. caughtSlugs was declared as a server-supplied override on PetGallery with a full consumption path, but no caller ever passed it — the dead optional prop whose override branch could never run. Removed; the client header-state source remains the only one, as it always was. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nch's own fixes The not-found page declared params and awaited it — Next constructs the not-found element with no props, so the destructure threw and every 404 rendered as a 500 (reproduced on a local dev server: /zh/xyz returned 500 with a TypeError). It now reads the locale from the request scope via getLocale and verified 404 again serves the localized page. The like recount's "same transaction" fix wrapped only the count statement; the write itself still ran outside, so the race the commit described was intact. Write and recount now ride one executeAtomicReturning batch in every branch (delete, insert, flip), with setLikeCount's cache invalidations on the response path. The rename sync updated only collection rows and covers — likes, metrics, collection requests, and featured_pet_slugs all key on pet_slug and were left dangling (likes reset, metrics lost). It also ran as three bare statements. All six tables now move in one executeAtomicReturning batch, plus cache invalidation for both slugs. The vibe cursor clamp disagreed with itself: LIMIT and slice used min(cursor, 600) but hasNext and nextCursor used the raw cursor, so paging across the cap served one duplicate page before terminating. The cursor clamps once at the entry and every derived value follows. Also: removed the revalidateGalleryPagePaths export that never gained a caller, and gave the docs quick-start verify step real copy again (the empty string rendered a check-icon row with no text). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pet-page-revalidate hand-built `/${locale}/pets/...` paths, tripping the
drift-check that forbids the literal form (the default locale is
unprefixed, so it 307s) — it now goes through withLocale like everywhere
else.
The pet-requests suite stubbed checkR2ObjectSize through a spread
mock.module of @/lib/r2; that module mock is process-wide and its undo
is unreliable across files on Bun 1.4.2, so in CI's file order it leaked
into asset-size-guard.test.ts and drove its real assertions with this
suite's stub. The suite now patches the singleton client's send method
in place — the same shape asset-size-guard.test already uses — and all
three suites pass in every load order.
The desktop-native build broke on a Zig type rule my earlier fix
introduced: f64 cannot represent maxInt(i64), so `v <= maxInt(i64)` is
a compile error. The bound is now the float literal 2^63, which every
finite f64 in [0, 2^63) casts to i64 losslessly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bare { R2_PUBLIC_BASE } replacement landed four seconds before
asset-size-guard did (same push), and submissions pulls the guard, so
whenever this integration loaded before a suite that needs the full r2
export set — CI's order, this week — the process-wide module cache
answered checkR2ObjectSize with "not found" and took the child suite
down with it. The spread keeps every export intact while still
overriding the base URL, the same pattern the ratelimit mock in the
same file already follows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same Zig type rule the main.zig settings read hit: f64 cannot represent maxInt(i64), so the comparison is a compile error, not a clamp. The 166-test zig suite passes with the literal bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The metrics route got this visibility contract two rounds ago; the like GET was still serving live counters for withdrawn or rejected slugs — the same takedown-confirmation signal a slug dictionary could use, from the endpoint nobody closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coverage audit found five of this branch's defenses had no regression test — a refactor could silently reopen each one. Pinned now: - admin-import's blocklist gate is extracted to src/lib/import-gate.ts (gateImportText) with unit tests for keyword hits in both text fields, a Chinese phrase, and URL-bearing free text; the script consumes the same function, so gate and script cannot drift apart. - the OG sprite loader exports loadFirstFrameAsDataUrl and a suite asserts a flat PNG declaring 8000x8000 (inside sharp's default ceiling) returns null while an atlas-sized sprite decodes and an off-allowlist URL never fetches. - requireSameOrigin gets a mangled-origin matrix (uppercase host, default port, non-default port, userinfo, punycode) so a future refactor back toward string matching turns red. - VIBE_MAX_CURSOR is exported and pinned as a small finite bound — the resource guard against a billion-row Postgres materialization. - the metrics route gets its first test file (404 for rejected and never-approved slugs, 200 with counters for approved), and the CLI's PETDEX_URL gate is extracted to petdex-url.ts with the same tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .env.example note added in f26ee57 claimed nothing reads AI_GATEWAY_API_KEY — backwards, and contradicting what 7441a74 had already established in AGENTS.md: the review/tag/sound model strings ("openai/gpt-5-mini") resolve through the AI SDK's default gateway provider, which reads only AI_GATEWAY_API_KEY, with no OPENAI fallback. The moderation checks in edit-policy are the ones that take AI_API_KEY (or OPENAI_API_KEY), and the WeChat QR validator reads OPENAI_API_KEY but has no caller. Both entries now say so. AGENTS.md's suite count read as six-plus-two for what is six total (four under {cli,profile}, plus collections/request and takedown-paths), and its ordering warning now also names profile-name.integration.tsx — the seventh process-wide-mocking suite whose bare r2 mock broke CI once — with the spread-the-real-module rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b6d3bd1 registered three bare mock.module factories (ratelimit, same-origin, db/metrics). A bare replacement is process-wide, so when this suite ran before like/withdraw in the same process their imports died with "Export named 'setLikeCount' not found" — the same leak 828620e fixed for profile-name. All three factories now spread the real module and override only the exports the route reads. The spreads resolve via dynamic import after the server-only stub registers: a static import hoists above the mocks and the real module tree throws. Also give the admin-import URL rejection its explanation back: the gateImportText extraction (in the same commit) dropped the URL_BLOCKED_REASON suffix the inline keyword check used to print, so a blocked credit_url no longer said why. The keyword branch keeps its own message, which names the keyword. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y images The metrics and like routes mark every error response no-store so an edge cache can never hold a stale 404/429 against a pet that was just approved; track-zip was the one sibling answering errors bare-headered. It is a POST, which CDNs don't cache by default, so this is uniformity rather than a reachable bug — the header costs nothing and keeps the contract the same across the family. The deploy stack also pins its base images to the digests currently running locally (oven/bun:1-slim, pgvector/pgvector:pg16, redis:7-alpine, hiett/serverless-redis-http:latest were all floating tags; latest most of all) so rebuilds reproduce. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reference guard Two defects the Codex full-branch review found in this branch's own fixes: The slug rename in applySubmissionAction committed the submitted_pets UPDATE as its own statement before migrating the slug-keyed references. A failure past that point left the row renamed while collections, likes, metrics and requests still pointed at the old slug, and a collection holding both the old and the new slug tripped the (collection_id, pet_slug) primary key mid-migration. The main-row UPDATE is now the batch's first statement and the reference migration rides in the same executeAtomicReturning call — and the migration gains the table the list always skipped: pet_requests.fulfilled_pet_slug, so a fulfilled request follows its pet's rename. The rename suite drives real PGlite SQL through both halves and pins the statement order. The withdraw cleanup's reference guard could never match: the keyList came from keyFromR2PublicUrl() (pets/...), while the URL columns reduced by the SQL regexp keep their leading slash (/pets/...). An IN between the two shapes is always false, so the guard was silently inert and the cleanup could delete a submission-shaped object another row still references. Both sides now compare after ltrim, and the suite asserts the rendered SQL strips the slash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… edges The remaining Codex review findings, by severity: - The metadata duplicate check scans only the newest 2,000 approved pets but never said so: the exact/visual/semantic checks each flag an incomplete scan, metadata did not, so a catalog past the limit could auto-approve a submission whose duplicate sat outside the window. findMetadataMatches now probes limit+1 and reports completeness, and the reviewer holds for manual review when the scan was truncated. - A request reference image whose object does not exist passed the size check — checkR2ObjectSize answers ok for NotFound, and the route only refused !ok, so a well-formed URL whose upload never happened became a request row pointing at nothing. The check now carries a `missing` flag (the size-guard callers keep their lenient retry semantics) and the request route refuses missing objects. - The Discord bot with no DISCORD_GUILD_ID in more than one guild posted announcements into whichever server held the first channel of the name. findChannel now fails closed in that shape; a single-guild bot stays valid. - The feedback-reply endpoint String()-coerced a non-string body, so an object reached the reply store and the notification email as "[object Object]". A non-string body is now a 400. - The deploy compose pins migrate and app to one shared PETDEX_DEPLOY_DATABASE_URL (replacing the app's DATABASE_URL alone left migrations running against the built-in postgres) and its mock-auth build arg now warns that the alias is baked in at build time — swapping Clerk keys at runtime does not restore real auth. - The CLI install stages all four temps before renaming, so the two install directories commit back-to-back instead of the second directory's writes waiting on the first's renames. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Codex third-pass review caught what the rename suite could not: the batch's pets UPDATE carried no RETURNING, so its result rows were empty and the value the function went on to use as the renamed pet was an empty array masquerading as a SubmittedPet — the rename itself landed, but the approve path's runPostApprovalEffects then read row.id, displayName and tags off undefined fields. (The suite passed because it only asserts result.ok and re-reads the database; a truthy empty array slips every falsy check.) The function now re-reads the row after the batch commits — RETURNING * yields snake_case keys the downstream code cannot consume, and a fresh read is safe because the batch has committed. The suite now also asserts the returned row carries the real id/slug/displayName, which fails on the empty-array shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stage list was completion-ordered, so with two directories writing in parallel a pet.json could rename before its spritesheet — the exact new-metadata-with-old-image pairing the atomic install exists to prevent, and the desktop loader takes the first spritesheet extension that exists. Sprites now stage and rename in their own phase, ahead of the metadata, regardless of which writeFile finished first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Codex third-pass review's PGlite probe caught the second half of the rename bug the RETURNING fix missed: the batch builds its SET list with sql.identifier over the update object's keys, which are the TS property names — approvedAt, rejectedAt, rejectionReason, displayName — while the database columns are snake_case. A quoted "approvedAt" is a case-sensitive identifier the table does not have, so every approve/reject/pending action and any edit touching displayName or description failed at runtime with "column does not exist". The slug-only rename test passed because "slug" is its own column name, which is exactly the gap an integration suite must not be the only net for. The SET list now maps each key through getTableColumns(submittedPets), and the suite drives a status-only action through the batch so the camelCase keys are exercised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The layer-3 review of the last five fix commits found no remaining defects, only two small drifts: findMetadataMatches could return hundreds of matches where the visual branch slices to SUBMISSION_SIMILARITY_MAX_RESULTS — the decision only reads the length, but the evidence JSON and review row grew unbounded; and the rename comment claimed the batch's own rows were the existence signal when the code actually discards them and re-reads. Both corrected. Also recorded, not patched: the getTableColumns key cast bypasses the compiler (a future patch key missing from the schema would only fail at runtime), the post-batch re-read has a rename race that stays unreachable until a production edit route exists, and the CLI's interrupted-install cleanup skips the other-extension rm — all noted for the follow-up list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion suite AGENTS.md documented dev paths but never mentioned the deployment stack this branch adds (Dockerfile + deploy compose, mock-auth build-time caveat, pinned digests), and its integration-suite count predated the rename suite: the isolated runner now carries seven suites, one of which — the rename suite — owns a PGlite database and executes real SQL instead of stubbing the mutation runner, and profile-name remains the eighth process-wide-mocking suite outside the runner. README and CONTRIBUTING gain the self-contained deployment row so the third path is discoverable outside the compose file's own header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hidden-codex-hook race struck again (documented in the PR comment from Oct 13); this tree has no desktop changes since the last green run. An empty commit re-rolls the leg. Upstream's crafter-station#822 is the real fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
|
The |
|
Deployment failed for project petdex with the following error: |
This branch has not been deployed
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.
Summary
This branch collects the fixes from a long, careful audit of Petdex — the pet
gallery, the desktop install flow, submission and review, the public API, the
CLI, the three translations, and the self-hosted deployment.
Every change is a small, self-contained commit that says what it fixes and why.
Nothing here is speculative: each one came from reading the code, reproducing
the defect, and leaving the reasoning in the commit message.
208 commits · 435 files. There are no new features except a self-contained
Docker deployment, covered in section 8.
How to review
Commit by commit is the easy path —
git log --oneline upstream/main..HEAD—because each commit is one idea. The sections below regroup them by area if you
would rather review by theme, and the Review rounds section
then lists every audit pass that produced them: what each pass covered, what it
found, which commit fixed it, and what was deliberately left for upstream.
If you would prefer this as several smaller PRs, I can split it. The themes
interleave through the history and share files, so each split would need its own
re-verify, but it is doable — just say the word.
One thing deliberately not here: the CLI's redesigned OAuth login callback
page. That is feature work in the standalone
packages/petdex-clipackagerather than a site fix, so it ships as its own PR — #820. Both branches are
cut from the same
upstream/maintip (7e327034c) and neither is behind it, soeither can merge first; the two touch three of the same CLI files
(
clerk-cli-auth.ts,clerk-cli-auth.test.ts,token-exchange.ts) withdifferent changes, so whichever merges second needs a trivial rebase.
What is fixed, by area
1. The site reads correctly in all three languages (en / es / zh)
A large share of the branch. The Spanish and Chinese translations are finished
rather than half-done: profiles, notifications, feedback, the request flow, the
facet pages, the Codex theme dialog, the claim banner, the taxonomy labels on
cards and chips, the card footers, and the page metadata titles. The last
hardcoded English strings on live pages are gone, dates and spritesheet copy are
localized instead of formatted once, and the OG copy that never resolved is
fixed. API error surfaces now return translatable codes rather than English
sentences.
2. Fewer errors, and none of them silent
Several routes answered
500to input a caller could send, and several failuresused to vanish:
nullparsed cleanly and then threw on the firstproperty read — those routes now answer
400logged nothing; the fetch handlers now catch it and surface a message
manual review instead of auto-rejecting
a blank screen
GET /api/pets/[slug]/codex-themecached its 404 and 422 branches at the edgefor the 24h its 200 advertises, so a pet approved — or a colour extracted —
during that window kept serving the miss; both branches now say
no-store,like the thumb/wastickers/variants siblings already did
POST /api/collections/[slug]/requestfor the same pair (adouble-click) hit the pending-pair unique index and 500'd where the pre-check
answers
alreadyPending; the conflict is now caught and answeredPATCH /api/profilerejectedhandle: nullwithhandle_too_shortbeforethe validator that calls it legal could run, so clearing the field 400'd the
whole save — a bio-only edit included
null) as valid and crashed every telemetry command, includingpetdex telemetry on/off, the documented way out; a corrupt credential filelikewise made every get/set/delete reject,
petdex login's own writeincluded
3. Writes that could race or half-apply
Multi-statement writes now run in one transaction through the existing
executeAtomicReturning, with the statements kept separate for the reason thathelper documents:
request fulfilment move together, guarded so a lost race reports
not_pendinginstead of re-resolving)
WHERE, so aconcurrent approve or claim cannot slip between the read and the write)
on the write
retried on the unique-constraint error
4. Input validation and trust boundaries
or the CLI bearer token, never the request body; a verified email is the only
thing written into
owner_emailrepeated on the write
Vercel branch of that check compares the scheme too, so an
http://twin of the deployment's own host no longer passes on a host whose
VERCEL_*env names itlonger fails open
subjects are sanitized at the point of interpolation
5. Assets and storage
hosts are resolved before validation rather than after
references by normalized key — the string-matched guard it shipped with let
a row point at another pet's live sprite or thumbnail and delete it
the declared one
scheduled cleanup
inflation is capped
ceiling; a folder submit read its spritesheet with no size check; and
installwrote its four files straight to their targets, so an interruptedinstall could pair new metadata with a stale image and leave the other
extension's file shadowing the fresh one in the desktop loader
6. Caching and SEO
og:urlis pinned to the canonical, and contradictory canonicals are gone/sitemap.xmlno longer renders dynamically; sticker collections are listedrevalidateactually applies(segment
revalidatedoes not cache a Route Handler that readssearchParams, so the 5-minute cache the comment promised never existed andevery download click walked the GitHub API)
it shipped with no invalidation at all, so a fresh approval stayed off
/leaderboard and the profile rank badge for the full 5-minute TTL
returned
[]on a DB error, and an empty array is cacheable, so onetransient blip pinned "every pet has zero metrics" for the whole TTL on any
gallery or profile page listing 50+ pets
7. Accessibility
<main>role="status"andaria-liveon swaps, toasts, and reply lists so a screenreader hears them
aria-labelon fields whose only name was a placeholder that vanishes on thefirst keystroke
aria-currenton the active filter,role="alert"on error paragraphsamber-on-light (~1.2:1) to the brand color
8. The CLI, the deployment, and the operational scripts
Dockerfile, a compose filewith Postgres, Redis and a migrate step, and
output: "standalone", sodocker compose upyields a working sitepreview by default and require
--applyto write; the re-org script'sdestructive collection drop needs a second
--drop-hollow"Done."
0600like the rest of
~/.petdex/runtime, not world-readable at the default umaskwhoami/submit/editno longer report a locked keychain or a failedrefresh as "Not signed in"; the underlying message is kept. A duplicate-check
that fails on a 5xx or a timeout now warns instead of silently submitting, so
it cannot mint a
-2slug the user never chose9. Keeping CI green
Two test files this branch added broke the app suite on Bun 1.4.2: one replaced
the
@/lib/r2module with a stub (process-wide, and the stub reachedr2.test.ts), and one neededdiscord.js, which the root install did notcarry. Both are fixed — the stubs now patch the singleton in place, and root
carries
discord.jsthe same way it already carries the CLI's dependencies.Review rounds
The thematic sections above fold in ~47 passes; the rounds below are the ones
that shipped as discrete, dated audit passes with their own findings, plus the
three-agent re-checks that closed the branch out. Each lists scope, findings,
the commit that fixed them, and anything recorded for upstream instead of
patched.
Round 48 — scripts, workers, discord bot, SEO
scripts/,workers/,packages/discord-bot, SEO surface.7297ed93,4ae6f378): the bot's/collectionand/installslugs pinned to the server-side alphabet
[a-z0-9-]so caller input cannotshape protocol-relative URLs in channel messages (
packages/discord-bot/src/index.ts);the
/installembed hardened with the webhook path'splainEmbedText;the three write scripts that ran on a bare invocation (
scripts/fix-shrimpy-issue-81.ts,scripts/apply-multi-pin.ts,scripts/rescue-failed-submissions.ts) gatedbehind
--apply; the assets worker stopped advertisingRangesupport itnever implemented (
workers/petdex-assets).Round 49 — takedown ownership, free-text bounds, search NUL
b2e1938d): owner self-service takedown restatesowner_id = actorIdinside every statement (
ownerScoped,src/lib/takedown.ts), closing aclaim-race where the route's pre-flight 403 read a snapshot the batch could
not see; the two free-text fields every sibling capped but these skipped are
bounded (owner takedown
reason, anonymous feedbackemail→ 254); NULbytes stripped from the search query before it reaches the Postgres ILIKE
pattern (
src/lib/pet-search.ts—?q=%00was a deterministic 500).Round 50 — client error text, desktop Zig UB
ed6b6656): submit/request error paths no longer render rawexception text ("Failed to fetch", zip-library messages with file paths) —
new generic copy in en/es/zh (
src/components/pets/pet-submit-form.tsx,request flow, three message files).
274da9f2: desktop settings reads thatcast arbitrary finite floats through
@intFromFloatnow range-check first —a persisted
1e300was a permanent boot crash and UB in ReleaseFast(
packages/petdex-desktop-native/src/main.zig).Round 51 — CLI scheme and echo, locale 404 CTAs
f3788633): the CLI requires https forPETDEX_URL(the baseURL that ships the bearer token had no scheme check;
packages/petdex-cli/src/petdex-url.ts,now result-returning
checkPetdexUrl) and strips control characters frommanifest
displayName/submittedBybefore they echo to the terminal(
packages/petdex-cli/src/manifest.ts); the locale 404 page and errorboundary route their CTAs through
withLocale(src/app/[locale]/not-found.tsx,error.tsx— Next passes no props to not-found, locale comes fromgetLocale()), and the locale switcher preserves the URL hash.Round 52 — vibe cursor cap, like GET gate, metrics visibility
f5cdacd5): the vibe-search offset caps atVIBE_MAX_CURSOR = 600(
src/lib/pet-search.ts:750) — the path materializesLIMIT = cursor + limitrows for its JS-side slice, so a hand-set cursor of 1e9 was a one-request
billion-row Postgres materialization on an uncacheable endpoint; the
anonymous like-counter GET reuses the publicCatalog IP limiter and slug
validation its POST already had;
/api/pets/[slug]/metricsanswers 404 fornon-approved slugs (
src/app/api/pets/[slug]/metrics/route.ts).Round 53 — presign ceiling, docs; gallery order and rename sync
f26ee579): pet-request reference images are HEAD-verifiedagainst the presign ceiling before the row lands (
src/app/api/pet-requests/route.ts)— the
requests/prefix sits outside every GC shape, so an oversized uploadwas a permanent public object nobody reclaims;
.env.exampledocuments theruntime switches.
24e4c9c6: gallery-order now implements its own contract —unlisted pets sorted ahead of the reordered block because the CASE's ELSE was
dead behind its WHERE (
src/lib/collection-order.ts); a slug rename throughthe admin edit path migrates collection member rows and covers in one batch
(
src/lib/submission-decisions.ts).small upload can be replayed into a large one between verification and
publication — closing it needs an upload-session design, not a patch; the
member-count cap is checked against a pre-lock snapshot, so two racing
PATCHes can exceed
MAX_COLLECTION_PETS.Round 54 — counters and pagination consistency
51cdad4a): the vibe path no longer publishes atotalthatgrows with the cursor (it was the fetch window's length); the like recount
runs inside the same transaction as the write (
src/app/api/pets/[slug]/like/route.ts);my-pets dates use the site locale;
og:locale/og:locale:alternateridethe OG tags; one untranslated es fragment fixed.
does not, and profile summaries include them while the leaderboard excludes
them — two source-universe splits that need product decisions.
Round 55 — admin imports, sharp decode gates
38282209): admin imports run the same keyword/URL blocklists thesubmission path always did, via a shared
gateImportText(src/lib/import-gate.ts,scripts/admin-import-pet.ts) with an explicit--skip-blocklistescapehatch —
--approvestill skips human review, but not the security baseline;the four
opengraph-image.tsxroutes apply the sprite decode-dimension gatepublish/color-extract/dhash already had — they were the last request paths
reaching sharp on an 8MB buffer with no pixel check, on public
cache-refreshable URLs.
with a wrong credit URL hands the pet to whoever owns that GitHub profile);
admin operations log to stdout only.
Round 56 — ISR purge, sticker sitemap tag, docs copy
dca721d4): owner takedowns purge the full-route/pets/[slug]ISR entries through
revalidatePetPagePaths(src/lib/pet-page-revalidate.ts)— data-cache tags never reached force-static route shells, so a deleted
pet's page kept serving broken sprite references for its 24h window; the
sticker sitemap's cache tag is now actually invalidated (
src/lib/sticker-sitemap.ts).265224a7: the docs page claimed CLI v0.1 (it is 1.4.0), walked users intothe retired
doctorcommand, and the download metadata said macOS-only —corrected across en/es/zh.
approvals lag 24h); facet pages cap at 60 pets with no pagination while
their count label reports the true total.
Round 57 — telemetry UTC, dead override, seam sweep
bac096d4): telemetry day buckets pinAT TIME ZONE 'UTC'likethe month buckets elsewhere (
src/lib/telemetry.ts); the never-passedcaughtSlugsserver-override prop on PetGallery is gone. The seam sweepotherwise came back clean: 956 sampled
t()literal keys resolve in allthree locales, prop wiring has no drift, each env boolean has one parsing
convention.
Round 58 — adversarial re-review of our own recent fixes (4 real regressions)
296551a0): the not-found page'sparamsdeclarationmade every 404 a 500 (Next passes no props to that element; reproduced
locally, zh/es CTAs still localized); the like recount's transaction wrapped
only the count while the write stayed outside — write + recount now share one
batch in all three branches (
src/app/api/pets/[slug]/like/route.ts); theslug-rename sync missed likes/metrics/requests/featured lists and ran
non-atomically — all six slug-keyed tables move in one batch
(
src/lib/submission-decisions.ts); the vibe cursor clamp disagreed withhasNext/nextCursor, serving a duplicate page across the cap(
src/lib/pet-search.ts).72789ccf.Round 59 — regression tests for the defenses; deploy re-verified
b6d3bd1b): five of this branch's defenses shipped without aregression test and now have one — the admin-import blocklist gate
(
src/lib/import-gate.test.ts), the OG sprite loader's decode gate(
src/app/[locale]/pets/[slug]/opengraph-image.test.ts), a mangled-originmatrix for
requireSameOrigin(src/lib/same-origin.test.ts), theVIBE_MAX_CURSORresource bound (src/lib/pet-search-nul-strip.test.ts),the metrics route's visibility contract (
src/app/api/pets/[slug]/metrics/route.test.ts),plus the CLI's
PETDEX_URLgate (packages/petdex-cli/src/petdex-url.test.ts).Two of these tests are mutation-checked: reverting the fix makes them fail.
Round 60 — closure sweep and docs contradictions
multi-revised areas (like, not-found, vibe each touched 2–3 times — no later
round silently undid an earlier fix).
25870863): an.env.examplenote claimed nothing readsAI_GATEWAY_API_KEYwhen the review/tag/sound model strings resolve throughthe gateway provider that reads only that key (contradicting AGENTS.md);
AGENTS.md's integration-suite count was self-inconsistent while omitting the
seventh process-wide-mocking suite.
Round 62 — black-box + container + adversarial recheck (3 agents)
adversarial recheck of the branch's own recent commits.
mock.modulefactories (
@/lib/ratelimit,@/lib/same-origin,@/lib/db/metrics) — abare replacement is process-wide, so when it ran before the like/withdraw
suites in one process their imports died with
Export named 'setLikeCount' not found(same leak class the r2 stub caused in section 9).7d928655): all three factories spread the real module(
src/app/api/pets/[slug]/metrics/route.test.ts), resolved via dynamicimport after the
server-onlystub registers — a static import hoists abovethe mocks and the real module tree throws. Also restores the
URL_BLOCKED_REASONexplanation thegateImportTextextraction dropped fromthe admin-import URL rejection (
scripts/admin-import-pet.ts)..envbaked into theimage, migrate logs carry no credentials.
inside the lock, a
csrf_blockedcontract assertion, ownerScoped takedownintegration coverage.
Round 63 — black-box + container audit + adversarial recheck (3 agents)
were track-zip answering errors without the family
Cache-Control: no-storeheader, and the deploy stack's four base-image tags floating.
446bb509):src/app/api/pets/[slug]/track-zip/route.tsaddsno-storeto its 429/400/404 (a POST is not edge-cached anyway, so this iscontract uniformity, not a reachable bug);
Dockerfileanddocker-compose.deploy.ymlpin all four base images to digests./zh/%00is normalized away by Next's router and falls backto a 200 locale homepage — no security impact, not worth fixing.
Round 64 — deep black-box + CLI/bot/scripts first pass (3 agents)
endpoints produced no 500s; Content-Type confusion converges to clean 4xx;
concurrent like/track-zip counting stays idempotent; error bodies never leak
internals. The pinned digests verified as multi-arch manifest lists. The
CLI↔app API contract matches field-for-field; the Discord bot sanitizes user
input down to the mention-injection edge case; all 83 one-off scripts have no
credential logging, no unscoped writes, no dead imports (CLI suite 202 pass,
bot 12 pass).
pet_approved/collection_featuredwebhook events theDiscord bot receives have no sender anywhere in this repo — the
showcase-broadcast trigger lives outside the codebase or the link is broken.
Recorded for upstream
Deliberately not patched here — each needs a design decision or touches
main-only code, listed so nothing silently disappears:
replayed larger between verification and publication; needs an upload-session
design.
MAX_COLLECTION_PETSchecked against a pre-lock snapshot — two racingPATCHes can exceed the cap; needs an in-statement guard like the
owner-collection cap has.
query, profile summaries vs leaderboard (discover pets), and facet pages
capped at 60 with no pagination.
the cache window.
the pet to whoever owns that GitHub profile.
/zh/%00router normalization falls back to a 200 homepage (cosmetic).Verification
bun run check(Biome) — cleanbunx tsc --noEmit— clean (app-ci runs this; there is no root script)bun run i18n:check— in syncbun run test— verified on Bun 1.4.2 (the version CI installs); CI is greenon the head commit
446bb509across all 16 checks(three locales, localized 404,
%00slug rejection, metrics/like/track-ziperror contracts)
Boundaries — what I did not verify
Stated plainly so nothing here is assumed to be more checked than it is:
were not walked through in a real browser
executeAtomicReturning's Neon-HTTP batch path (production) applies everystatement in one transaction per Neon's documentation, which is not
verified locally — the local drivers take the callback-transaction path.
This is noted in
AGENTS.mdthe Cloudflare-in-front topology this app deploys behind (noted in code)
src/libare currently unreferenced — the admincandidate-review half of
request-candidates.tsand the telemetry read side.Their races were hardened, but the missing UI or route that would call them is
your decision, not something this branch invents
timeZone, so a browserin a non-UTC zone can hydrate a different string than the server rendered.
This predates the branch (upstream
mainhas the same calls) and is leftalone rather than swept in here
only; the webp↔png switch was not watched on a running desktop build
The collection-request test deliberately stubs the db so its pre-check misses
and the insert itself raises the conflict — seeding a pending row would let the
assertion pass with the fix reverted.
Round 65 (Codex, via Orca — full 202-commit re-review, 360 commands, gpt-6.1-sol at max effort, read-only, report preserved in the branch's working notes): 0 blocking / 6 important / 3 nit. The branch's five most recent commits were re-verified, the two upstream commits were confirmed conflict-free (
merge-treeclean), and six substantive findings came back — three of them in this branch's own fixes, all now fixed in9166e93band0107908d:submitted_petswas updated as its own statement, then the six slug-keyed tables were migrated in a second batch; a failure between them left a renamed pet with collections/likes/metrics still on the old slug, and a collection holding both slugs tripped its composite primary key. The main-row UPDATE is now the batch's first statement, and the migration gains the table the list always skipped —pet_requests.fulfilled_pet_slug— so a fulfilled request follows its pet. Covered by a new PGlite suite that drives the real SQL both ways.keyFromR2PublicUrl()yieldspets/...while the SQL that reduces the stored URL column keeps/pets/...; anINbetween the two shapes is always false, so the guard was silent and the cleanup could delete an object another row referenced. Both sides now compare afterltrim, and the rendered SQL is asserted.limit+1and the reviewer holds.Also fixed: a request reference image whose object does not exist passed the size check (
checkR2ObjectSizenow answers ok with amissingflag; the size-guard callers keep their lenient retry semantics and the request route refuses missing objects); the Discord bot with noDISCORD_GUILD_IDin more than one guild announced into whichever server held the first channel of the name (fails closed now, single-guild bots unaffected); the feedback-reply endpointString()-coerced a non-string body into the reply store and the notification email; the deploy compose pinnedmigrateto the built-in Postgres while its own comments told you to repoint the app'sDATABASE_URL(both services now sharePETDEX_DEPLOY_DATABASE_URL), and itsPETDEX_MOCK_AUTHbuild arg now warns that the Clerk alias is baked in at build time — swapping in real keys at runtime does not restore authentication; and the CLI's install stages all four temp files before renaming any of them, so the two install directories commit back-to-back.Round 68 (layer-3 adversarial recheck of the five fix commits + black-box old-vs-new stack comparison + first-pass sweep of the never-audited corners, 3 agents): no remaining defects. The layer-3 pass found only two drifts, fixed in
b68a06f6— the metadata duplicate match list could return hundreds of entries where the visual branch caps atSUBMISSION_SIMILARITY_MAX_RESULTS(the decision reads only the length, but the evidence JSON grew unbounded), and the rename comment claimed the batch's own rows were the existence signal when the code discards them and re-reads. Everything else is recorded, not patched: thegetTableColumnskey cast bypasses the compiler (a future patch key missing from the schema would fail at runtime), the post-batch re-read has a rename race that stays unreachable until a production edit route exists, and the CLI's interrupted-install cleanup skips the other-extension rm — plus three upstream questions from the corners sweep (the SETUP.md-promised/api/discord/webhookhas no in-repo implementation, the assets worker still serves its retired workers.dev subdomain, andpatch-native-packager.shhas no caller). The black-box agent rebuilt the pre-fix image, reproduced the[object Object]reply bug live on it, then verified the rebuilt stack answers400 invalid_body— the deploy stack now runsb68a06f6with all baseline probes green.🤖 Generated with Claude Code