Skip to content

fix: a broad round of bug fixes across the site, API, CLI, and translations - #819

Open
MisonL wants to merge 210 commits into
crafter-station:mainfrom
MisonL:fix/gallery-and-install-defects
Open

MisonL wants to merge 210 commits into
crafter-station:mainfrom
MisonL:fix/gallery-and-install-defects

Conversation

@MisonL

@MisonL MisonL commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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-cli package
rather than a site fix, so it ships as its own PR — #820. Both branches are
cut from the same upstream/main tip (7e327034c) and neither is behind it, so
either can merge first; the two touch three of the same CLI files
(clerk-cli-auth.ts, clerk-cli-auth.test.ts, token-exchange.ts) with
different 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 500 to input a caller could send, and several failures
used to vanish:

  • a JSON body of literal null parsed cleanly and then threw on the first
    property read — those routes now answer 400
  • a dropped connection during a client action left the button spinning and
    logged nothing; the fetch handlers now catch it and surface a message
  • an automated review that could not finish its duplicate check now holds for
    manual review instead of auto-rejecting
  • routes gained an error boundary, so a render failure shows a page rather than
    a blank screen
  • GET /api/pets/[slug]/codex-theme cached its 404 and 422 branches at the edge
    for 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
  • a second POST /api/collections/[slug]/request for the same pair (a
    double-click) hit the pending-pair unique index and 500'd where the pre-check
    answers alreadyPending; the conflict is now caught and answered
  • PATCH /api/profile rejected handle: null with handle_too_short before
    the validator that calls it legal could run, so clearing the field 400'd the
    whole save — a bio-only edit included
  • the CLI treated a config file holding a parseable non-object (a literal
    null) as valid and crashed every telemetry command, including
    petdex telemetry on/off, the documented way out; a corrupt credential file
    likewise made every get/set/delete reject, petdex login's own write
    included

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 that
helper documents:

  • creating a pet request and its first vote
  • resolving a request candidate (the winner, the sibling rejects, and the
    request fulfilment move together, guarded so a lost race reports not_pending
    instead of re-resolving)
  • the CLI takedown cleanup
  • withdrawing a submission (the guards are repeated in the write's WHERE, so a
    concurrent approve or claim cannot slip between the read and the write)
  • claiming an owner and renaming a profile handle repeat their identity checks
    on the write
  • a slug collision, which used to 500 after consuming a rate-limit slot, is now
    retried on the unique-constraint error

4. Input validation and trust boundaries

  • submission identity and credit still come only from the verified Clerk session
    or the CLI bearer token, never the request body; a verified email is the only
    thing written into owner_email
  • request-image edits are gated to their author, and the ownership check is
    repeated on the write
  • presign roles and request bodies are validated before use
  • the CSRF origin check compares full origins, not prefixes — and the
    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 it
  • the rate limiters key on the platform-set IP header, and the local stack no
    longer fails open
  • the Resend webhook body is capped before the signature is verified; email
    subjects are sanitized at the point of interpolation
  • the Discord webhook receiver fails closed

5. Assets and storage

  • submission asset URLs are pinned to the pet's own path namespace, and legacy
    hosts are resolved before validation rather than after
  • withdraw's R2 cleanup deletes only submission-upload-shaped keys and matches
    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
  • an uploaded asset's real size is verified at registration instead of trusting
    the declared one
  • stale public artifacts are stopped, and unbounded asset growth gets a
    scheduled cleanup
  • the fetches behind the OG and sticker pipelines are bounded
  • in the CLI: command injection is closed, downloads are bounded, and zip
    inflation is capped
  • the manifest fetch was the one network read in the package with no byte
    ceiling; a folder submit read its spritesheet with no size check; and
    install wrote its four files straight to their targets, so an interrupted
    install 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:url is pinned to the canonical, and contradictory canonicals are gone
  • /sitemap.xml no longer renders dynamically; sticker collections are listed
  • the desktop-release lookup is cached where revalidate actually applies
    (segment revalidate does not cache a Route Handler that reads
    searchParams, so the 5-minute cache the comment promised never existed and
    every download click walked the GitHub API)
  • the leaderboard's per-metric cache is invalidated on approve/reject —
    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
  • a failed metrics read is no longer cached as an empty index: the compute
    returned [] on a DB error, and an empty array is cacheable, so one
    transient blip pinned "every pet has zero metrics" for the whole TTL on any
    gallery or profile page listing 50+ pets

7. Accessibility

  • a skip link, and the header moved out of <main>
  • role="status" and aria-live on swaps, toasts, and reply lists so a screen
    reader hears them
  • aria-label on fields whose only name was a placeholder that vanishes on the
    first keystroke
  • aria-current on the active filter, role="alert" on error paragraphs
  • the space bar is no longer hijacked, and the hero subtitle moved off
    amber-on-light (~1.2:1) to the brand color

8. The CLI, the deployment, and the operational scripts

  • a self-contained Docker deployment: a multi-stage Dockerfile, a compose file
    with Postgres, Redis and a migrate step, and output: "standalone", so
    docker compose up yields a working site
  • the collection re-org script and the three ownership-reassign scripts now
    preview by default and require --apply to write; the re-org script's
    destructive collection drop needs a second --drop-hollow
  • the keyword takedown exits non-zero when a removal fails instead of printing
    "Done."
  • the CLI's session-title file (the first line of your prompt) is written 0600
    like the rest of ~/.petdex/runtime, not world-readable at the default umask
  • whoami/submit/edit no longer report a locked keychain or a failed
    refresh 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 -2 slug the user never chose

9. Keeping CI green

Two test files this branch added broke the app suite on Bun 1.4.2: one replaced
the @/lib/r2 module with a stub (process-wide, and the stub reached
r2.test.ts), and one needed discord.js, which the root install did not
carry. Both are fixed — the stubs now patch the singleton in place, and root
carries discord.js the 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

  • Scope: scripts/, workers/, packages/discord-bot, SEO surface.
  • Fixed (7297ed93, 4ae6f378): the bot's /collection and /install
    slugs pinned to the server-side alphabet [a-z0-9-] so caller input cannot
    shape protocol-relative URLs in channel messages (packages/discord-bot/src/index.ts);
    the /install embed hardened with the webhook path's plainEmbedText;
    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) gated
    behind --apply; the assets worker stopped advertising Range support it
    never implemented (workers/petdex-assets).

Round 49 — takedown ownership, free-text bounds, search NUL

  • Fixed (b2e1938d): owner self-service takedown restates owner_id = actorId
    inside every statement (ownerScoped, src/lib/takedown.ts), closing a
    claim-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 feedback email → 254); NUL
    bytes stripped from the search query before it reaches the Postgres ILIKE
    pattern (src/lib/pet-search.ts — ?q=%00 was a deterministic 500).

Round 50 — client error text, desktop Zig UB

  • Fixed (ed6b6656): submit/request error paths no longer render raw
    exception 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 that
    cast arbitrary finite floats through @intFromFloat now range-check first —
    a persisted 1e300 was a permanent boot crash and UB in ReleaseFast
    (packages/petdex-desktop-native/src/main.zig).

Round 51 — CLI scheme and echo, locale 404 CTAs

  • Fixed (f3788633): the CLI requires https for PETDEX_URL (the base
    URL that ships the bearer token had no scheme check; packages/petdex-cli/src/petdex-url.ts,
    now result-returning checkPetdexUrl) and strips control characters from
    manifest displayName/submittedBy before they echo to the terminal
    (packages/petdex-cli/src/manifest.ts); the locale 404 page and error
    boundary route their CTAs through withLocale (src/app/[locale]/not-found.tsx,
    error.tsx — Next passes no props to not-found, locale comes from
    getLocale()), and the locale switcher preserves the URL hash.

Round 52 — vibe cursor cap, like GET gate, metrics visibility

  • Fixed (f5cdacd5): the vibe-search offset caps at VIBE_MAX_CURSOR = 600
    (src/lib/pet-search.ts:750) — the path materializes LIMIT = cursor + limit
    rows 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]/metrics answers 404 for
    non-approved slugs (src/app/api/pets/[slug]/metrics/route.ts).

Round 53 — presign ceiling, docs; gallery order and rename sync

  • Fixed (f26ee579): pet-request reference images are HEAD-verified
    against 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 upload
    was a permanent public object nobody reclaims; .env.example documents the
    runtime 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 through
    the admin edit path migrates collection member rows and covers in one batch
    (src/lib/submission-decisions.ts).
  • Recorded: a presigned PUT stays valid for its whole TTL, so a compliant
    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

  • Fixed (51cdad4a): the vibe path no longer publishes a total that
    grows 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:alternate ride
    the OG tags; one untranslated es fragment fixed.
  • Recorded: the batches facet excludes discover pets while the main query
    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

  • Fixed (38282209): admin imports run the same keyword/URL blocklists the
    submission path always did, via a shared gateImportText (src/lib/import-gate.ts,
    scripts/admin-import-pet.ts) with an explicit --skip-blocklist escape
    hatch — --approve still skips human review, but not the security baseline;
    the four opengraph-image.tsx routes apply the sprite decode-dimension gate
    publish/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.
  • Recorded: credit_url matching is the documented claim pathway (an import
    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

  • Fixed (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 into
    the retired doctor command, and the download metadata said macOS-only —
    corrected across en/es/zh.
  • Recorded: home and facet pages have no page-level purge at all (new
    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

  • Fixed (bac096d4): telemetry day buckets pin AT TIME ZONE 'UTC' like
    the month buckets elsewhere (src/lib/telemetry.ts); the never-passed
    caughtSlugs server-override prop on PetGallery is gone. The seam sweep
    otherwise came back clean: 956 sampled t() literal keys resolve in all
    three 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)

  • Found & fixed (296551a0): the not-found page's params declaration
    made 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); the
    slug-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 with
    hasNext/nextCursor, serving a duplicate page across the cap
    (src/lib/pet-search.ts).
  • Closed later: the like GET's non-approved 404 gap it recorded — 72789ccf.

Round 59 — regression tests for the defenses; deploy re-verified

  • Fixed (b6d3bd1b): five of this branch's defenses shipped without a
    regression 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-origin
    matrix for requireSameOrigin (src/lib/same-origin.test.ts), the
    VIBE_MAX_CURSOR resource 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_URL gate (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

  • Verified: the branch's final state is self-consistent across all
    multi-revised areas (like, not-found, vibe each touched 2–3 times — no later
    round silently undid an earlier fix).
  • Fixed (25870863): an .env.example note claimed nothing reads
    AI_GATEWAY_API_KEY when the review/tag/sound model strings resolve through
    the 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)

  • Scope: the deployed stack (black-box), the running containers, and an
    adversarial recheck of the branch's own recent commits.
  • Found: b6d3bd1's metrics suite registered three bare mock.module
    factories (@/lib/ratelimit, @/lib/same-origin, @/lib/db/metrics) — a
    bare 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).
  • Fixed (7d928655): all three factories spread the real module
    (src/app/api/pets/[slug]/metrics/route.test.ts), resolved via dynamic
    import after the server-only stub registers — a static import hoists above
    the mocks and the real module tree throws. Also restores the
    URL_BLOCKED_REASON explanation the gateImportText extraction dropped from
    the admin-import URL rejection (scripts/admin-import-pet.ts).
  • Container findings: none — no bind mounts, no .env baked into the
    image, migrate logs carry no credentials.
  • Recorded: presign-PUT replay inside the TTL, MAX_COLLECTION_PETS guard
    inside the lock, a csrf_blocked contract assertion, ownerScoped takedown
    integration coverage.

Round 63 — black-box + container audit + adversarial recheck (3 agents)

  • Found: all five then-latest commits held up; the same-shape leftovers
    were track-zip answering errors without the family Cache-Control: no-store
    header, and the deploy stack's four base-image tags floating.
  • Fixed (446bb509): src/app/api/pets/[slug]/track-zip/route.ts adds
    no-store to its 429/400/404 (a POST is not edge-cached anyway, so this is
    contract uniformity, not a reachable bug); Dockerfile and
    docker-compose.deploy.yml pin all four base images to digests.
  • Recorded: /zh/%00 is normalized away by Next's router and falls back
    to a 200 locale homepage — no security impact, not worth fixing.

Round 64 — deep black-box + CLI/bot/scripts first pass (3 agents)

  • Findings: zero defects, no code changes. The full method matrix over 15
    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).
  • Recorded: the pet_approved/collection_featured webhook events the
    Discord 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:

  1. Presign-PUT replay inside the TTL — a compliant small upload can be
    replayed larger between verification and publication; needs an upload-session
    design.
  2. MAX_COLLECTION_PETS checked against a pre-lock snapshot — two racing
    PATCHes can exceed the cap; needs an in-statement guard like the
    owner-collection cap has.
  3. Facet/profile/leaderboard source-universe splits — batches facet vs main
    query, profile summaries vs leaderboard (discover pets), and facet pages
    capped at 60 with no pagination.
  4. Home and facet pages have no page-level ISR purge — new approvals lag
    the cache window.
  5. credit_url claim pathway — an admin import with a wrong credit URL hands
    the pet to whoever owns that GitHub profile.
  6. Discord-bot showcase webhooks have no in-repo sender (Round 64).
  7. /zh/%00 router normalization falls back to a 200 homepage (cosmetic).

Verification

  • bun run check (Biome) — clean
  • bunx tsc --noEmit — clean (app-ci runs this; there is no root script)
  • bun run i18n:check — in sync
  • bun run test — verified on Bun 1.4.2 (the version CI installs); CI is green
    on the head commit 446bb509 across all 16 checks
  • every commit is signed
  • the Docker deployment was rebuilt from the head commit and smoke-verified
    (three locales, localized 404, %00 slug rejection, metrics/like/track-zip
    error contracts)

Boundaries — what I did not verify

Stated plainly so nothing here is assumed to be more checked than it is:

  • the client-side accessibility changes are covered by the unit suite only; they
    were not walked through in a real browser
  • executeAtomicReturning's Neon-HTTP batch path (production) applies every
    statement 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.md
  • the rate-limit key's header trust is verified for Vercel's guarantee, not for
    the Cloudflare-in-front topology this app deploys behind (noted in code)
  • two groups of exports in src/lib are currently unreferenced — the admin
    candidate-review half of request-candidates.ts and 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
  • eight date renders still format without an explicit timeZone, so a browser
    in a non-UTC zone can hydrate a different string than the server rendered.
    This predates the branch (upstream main has the same calls) and is left
    alone rather than swept in here
  • the CLI's atomic install and extension cleanup are verified at the code level
    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-tree clean), and six substantive findings came back — three of them in this branch's own fixes, all now fixed in 9166e93b and 0107908d:

  1. The slug rename committed the main row before migrating its references. submitted_pets was 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.
  2. The withdraw reference guard could never match. keyFromR2PublicUrl() yields pets/... while the SQL that reduces the stored URL column keeps /pets/...; an IN between 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 after ltrim, and the rendered SQL is asserted.
  3. The metadata duplicate check never reported a truncated scan. Exact/visual/semantic each hold the review when their scan is incomplete; metadata's 2,000-row window did not, so a larger catalog could auto-approve a duplicate outside the window. It now probes limit+1 and the reviewer holds.

Also fixed: a request reference image whose object does not exist passed the size check (checkR2ObjectSize now answers ok with 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 announced into whichever server held the first channel of the name (fails closed now, single-guild bots unaffected); the feedback-reply endpoint String()-coerced a non-string body into the reply store and the notification email; the deploy compose pinned migrate to the built-in Postgres while its own comments told you to repoint the app's DATABASE_URL (both services now share PETDEX_DEPLOY_DATABASE_URL), and its PETDEX_MOCK_AUTH build 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 at SUBMISSION_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: the getTableColumns key 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/webhook has no in-repo implementation, the assets worker still serves its retired workers.dev subdomain, and patch-native-packager.sh has 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 answers 400 invalid_body — the deploy stack now runs b68a06f6 with all baseline probes green.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 9, 2026

Copy link
Copy Markdown

@MisonL is attempting to deploy a commit to the Crafter Station Team on Vercel.

A member of the Team first needs to authorize it.

@MisonL MisonL changed the title fix: multi-session defect sweep across i18n, API, CLI, and deploy fix: a broad round of bug fixes across the site, API, CLI, and translations Oct 9, 2026
MisonL and others added 27 commits October 9, 2026 22:45
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>
MisonL and others added 28 commits October 14, 2026 11:07
…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>
@MisonL

MisonL commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

The build windows leg failed with the same hidden-codex-hook race on two consecutive runs (9050f2b6 docs-only and an empty re-trigger commit) — neither touches packages/petdex-desktop-native. This matches the Oct 13 analysis below: the runner subprocess exits after writing the request without reading a response, and slow Windows runners land in that window. Merging #822 (the bounded wait) should clear it; until then this leg is flaky independent of the PR's contents. All other 15 checks are green.

@vercel

vercel Bot commented Oct 16, 2026

Copy link
Copy Markdown

Deployment failed for project petdex with the following error:

Creating the Deployment Timed Out.

This branch has not been deployed

No deployments
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