deckhearth/AGENTS.md
Randall Stillwell 7832e03dec docs: post-convoy cleanup for add-rate-limiting — MILESTONE, last P0 closed
Reflects the merged add-rate-limiting convoy (PR #20, squash commit
708ef45) in repo documentation. **This is the milestone cleanup** —
add-rate-limiting closed P0 #6 (No rate limiting anywhere), the LAST
open P0 ship-blocker. `.convoys/ship-readiness.md`'s § Status summary
flips from "7 of 8 RESOLVED; 1 remains" to **"8 of 8 RESOLVED.
Launch-readiness P0 checklist is empty."** One brief in the convoy:
Brief 1 shipped as planned with no scope expansions and no implementer
deviations from the verbatim spec; all six architect decisions ratified
verbatim at gate 1 (D1 operator-ratified Option A; D2-D6
architect-self-ratified).

.convoys/add-rate-limiting.md:
  - frontmatter status: in-progress -> shipped (added shipped: 2026-05-24)
  - new ## As-shipped section. Opens with the milestone language
    pointing back at ship-readiness.md's flipped § Status summary.
    Decisions section captures all 6 ratifications (D1 operator-
    ratified Option A — Critical: WHY the atomic admin UI fix in
    pages/admin/card-import.js was Decision 1's hidden coupling
    requirement, since API gating alone would have broken every
    "Import Cards" click; D2 hybrid named-limiter shape with
    Map<className, Ratelimit> cache; D3 per-class table including
    the two D3 tuning-evidence raises — search 30 -> 60/min because
    ShareModal.handleSearch has no debounce so a 17-char email = 16
    requests in <5s, and generate kept at 5/hour because DiceBear is
    free not paid AI; D4 two-extractor shape with defensive THROW
    on null/empty userId; D5 uniform 429 message; D6 no new vitest
    or playwright specs deferred to fill-vitest-handler-coverage).
    As-shipped surface broken into 4 layers (1 lib refactor + 6 route
    gates + 1 atomic admin UI fix + 1 rule extension) mirroring the
    cors-tighten cleanup's pattern-split shape. Empirical CI metrics
    from post-merge run 26382185019 (Playwright smoke 59s 3/3 in
    3.8s, forbidden-cors-headers pass, vitest 21/21, lint 128 baseline,
    Screenshot diff continue-on-error swallow per Decision 4).
    Cross-validation finding: smoke test 2 still passes against the
    post-rate-limit preview — that's three convoys in a row (PR #15
    Layout default-user, PR #19 CORS-tighten, PR #20 rate-limiting)
    where the same 3-test smoke spec defended the auth surface
    through sweeping changes. Operator-action-required: none. What
    did NOT change audit trail.

.convoys/ship-readiness.md:
  - § Status summary at the top flipped from 7/8 to 8/8 RESOLVED.
    Header text updated to "Launch-readiness P0 checklist is empty."
    P0 #6 row in the table flips from PARTIAL to RESOLVED with the
    two-convoy lineage (fix-auth-bypass Brief 4 + add-rate-limiting).
    Trailing paragraph rewritten as a milestone note: security gate
    closed; remaining launch work is P1 quality bar + P2/P3 polish.
  - P0 #6 entry flipped from PARTIAL to RESOLVED with the full
    add-rate-limiting as-shipped block (8 sub-bullets covering the
    lib refactor shape, the per-class table, the defensive THROW,
    the three import routes' auth-gating, the atomic admin UI fix
    and WHY, the rule extension, the 6 decisions, and the diff
    breakdown). Brief 4's 2026-05-23 partial is preserved as the
    prior as-shipped layer to maintain the audit trail.
  - Launch sequence step 4 marked RESOLVED 2026-05-24 with the
    squash commit + smoke metrics inline.
  - Queued convoys: removed the add-rate-limiting entry (it shipped).
    Added a new delete-dead-lorcana-import entry (P3 polish; the
    Lorcana import route was gated defensively in PR #20 despite
    zero current frontend callers — pages/admin/card-import.js's
    <select> only offers mtg + pokemon — so if Lorcana stays
    permanently out of the admin UI, this is the cleanup PR).
    Added three "flagged but kept out of scope" follow-ups per the
    convoy's § What did NOT change: harden-multipart-parser (P2;
    5MB body still consumed before the 429 path on avatar.js),
    god-function-split / refactor-cards-search-sql (P2; 240-line
    7-branch SQL in cards/search.js), and withAdmin(handler) wrapper
    extraction (P3 DX; the three import routes are call sites #3-5
    in the codebase but uniform inline shape was preserved for
    convoy atomicity). Updated tighten-visual-diff-path-filter to
    note PR #20 also tripped the same false-positive.

AGENTS.md:
  - Gotcha #12 extended end-to-end. Was the single-class auth-only
    lib + the env-var contract; is now the 5-class reality with a
    full per-class table (helper / limit-window / key / routes),
    the defensive THROW pattern in extractUserIdentifier, the
    gate-ordering rule for per-user limiters, and the
    auth → admin-role → rate-limit ordering for the three import
    routes. Prominent milestone line opens the new content:
    "add-rate-limiting convoy (squash 708ef45, PR #20, 2026-05-24)
    closed P0 #6 — all 8 P0s now RESOLVED." Original env-var
    contract paragraph (KV_REST_API_URL / KV_REST_API_TOKEN,
    fail-closed-in-prod / warn-and-noop-in-dev) is preserved
    verbatim above the new content.
  - § 6 Testing: intentionally untouched (no test surface changed;
    vitest 21/21 and smoke 3/3 still apply).
  - § 7 Deployment: intentionally untouched (no deployment-shape
    changed; same KV_REST_API_* env vars from Brief 4).

.cursor/rules/api-routes.mdc:
  - The implementer extended § Rate limiting in PR #20 with the
    per-class table + verbatim call shape + gate-ordering rules +
    identifier-extraction + uniform 429 + fail-closed env-var
    contract + fail-open Upstash-outage behavior. Doc-writer pass
    verified completeness; added a one-sentence convoy-attribution
    line at the top of § Rate limiting citing the two-convoy
    lineage (fix-auth-bypass Brief 4 for the auth class +
    add-rate-limiting for the other four classes and 7 newly-gated
    routes), mirroring the post-cors-tighten § CORS attribution
    shape. No other touch-ups needed.

No changes to: package.json, package-lock.json, lib/rate-limit.js,
pages/**, components/**, scripts/**, test/**, tests/**,
.github/workflows/**, README.md, TESTING_GUIDE.md, playwright.config.js,
eslint.config.mjs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:09:07 -05:00

23 KiB

AGENTS.md — AI collaboration (tcg-vault)

Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.

Branding note: the repo, README, and seed data say "TCG Vault" and admin@tcgvault.com, but the Layout component renders "Deck Hearth". Pick one before launch — see .convoys/ for tracking.

1. Project overview

A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.

  • Framework: Next.js 16 (Pages router) + React 18, JavaScript (not TypeScript — see Gotcha #9)
  • Data: Neon Postgres, accessed two different ways — @neondatabase/serverless (lib/database.js) AND raw @vercel/postgres (pages/api/**). Pick ONE; see Gotcha #1.
  • Auth: Custom JWT (jsonwebtoken + bcryptjs), token stored in localStorage, sent as Authorization: Bearer …. No NextAuth. The secret + canonical 24h TTL come from lib/auth-secret.js (single source of truth; throws at module load if JWT_SECRET is unset). getUserFromRequest returns null for unauthenticated requests — no synthetic admin fallback — and login + register are rate-limited (5 attempts / 15 min via @upstash/ratelimit). The seed admin row is created at admin@tcgvault.com with a password supplied via the required ADMIN_INITIAL_PASSWORD env var (scripts/setup-neon-db.js exits with code 1 before touching the DB if the var is unset); no credential ships in the source tree. Operators of envs that pre-date the drop-public-setup convoy still have the old admin123 hash in their DB — rotate manually via the app (see Gotcha #4).
  • UI: Tailwind CSS + custom CSS variables for theming (light/dark via lib/theme-context.js)
  • Hosting: Vercel (vercel.json, .vercel/ present)

2. Architecture quick reference

Area Path Notes
Pages router views pages/*.js Public + auth views; uses components/Layout.js
API routes pages/api/**/*.js Express-style handler(req, res). 30+ handlers depend on lib/permission-middleware.js::getUserFromRequest
Shared UI components/*.js Layout, CardItem, CameraScanner, modal family
Auth + DB libs lib/*.js auth-context, admin-auth, use-auth (three parallel auth surfaces), database, permission-middleware
Migration scripts scripts/*.js 27+ one-off "add column" / "seed" scripts. No formal migration tool
Card-import jobs pages/api/cards/import-*.js, scripts/import-*.js Scryfall / Lorcana / Pokémon TCG APIs
Database schema scripts/setup-neon-db.js Bootstrap SQL DDL — the source of truth until a real migration tool lands
Schema map docs/SCHEMA_MAP.md Hand-curated; regenerate after schema changes

Code graph is indexed by user-code-review-graph MCP (122 files, 628 nodes, 5602 edges). Ask: "what calls getUserFromRequest?" before refactoring auth.

3. Key conventions

  • Auth (server): import { getUserFromRequest } from '../../lib/permission-middleware' → returns { userId, email, role } or null. null means "send 401" — always early-return when the user is null before doing any work that depends on their identity.
  • Auth (client): import { useAuth } from '../lib/use-auth'. Avoid lib/auth-context.js and lib/admin-auth.js for new code — they are legacy parallel implementations.
  • Layout user prop: pages should pass user from useAuth() to <Layout>. Layout's default is null and renders a logged-out "Sign in" CTA when no user is supplied — both paths are valid (some surfaces like pages/invite/{accept,decline}.js legitimately render Layout for anonymous visitors). Do not reintroduce a hardcoded user object as a default prop.
  • JWT secret + TTL: import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'. This is the only place either value is defined; do not reintroduce literal fallbacks. JWT_TOKEN_TTL = '24h' is canonical.
  • Auth helper (token mint / verify / password hash): import { ... } from '../../pages/api/auth-utils' (generateToken, verifyToken, hashPassword, verifyPassword). Reads the secret + TTL from lib/auth-secret.js under the hood.
  • Rate limiting: import { checkAuthRateLimit } from '../../lib/rate-limit.js' for any new auth-surface endpoint (/api/auth/login + /api/auth/register already wired). Returns { allowed, remaining, reset }; on !allowed return 429 with a Retry-After header. See .cursor/rules/api-routes.mdc § "Rate limiting" for the verbatim shape.
  • Permission gate for collection routes: wrap handlers with withCollectionPermission('viewer' | 'editor' | 'owner') from lib/permission-middleware.js.
  • DB access: Use tagged-template style — import { sql } from '@vercel/postgres'. Avoid the legacy lib/database.js db.query(string, params) API; its parameter interpolation uses sql.unsafe and is a SQL-injection vector.
  • Activity logging: logCollectionActivity(collectionId, userId, action, details) — call it from any handler that mutates a collection.
  • File names: kebab-case.js for libs/scripts; PascalCase.js for React components.
  • Imports: No path aliases configured; use relative imports.
  • Slugs: lib/slug-utils.js::generateUniqueSlug for any user-facing identifier (collections, decks).
  • CSS theme tokens: Components read var(--bg-primary), var(--text-primary), var(--accent-ember), etc. — defined in styles/. Don't hardcode hex colors.

4. Common gotchas

  • #1 — Two SQL clients live in parallel. @neondatabase/serverless (used by lib/database.js) and @vercel/postgres (used by most pages/api/** handlers). New code: prefer @vercel/postgres tagged templates. Migration to a single client is tracked in .convoys/.

  • #2 — getUserFromRequest synthetic-admin fallback. RESOLVED by fix-auth-bypass Brief 2 (commit 258e479). The helper now returns null for unauthenticated requests; pages/api/auth/verify.js returns 401 on the no-token branch. The 16 unit tests in test/lib/permission-middleware.test.js lock in the contract, including a negative regression against the old synthetic-admin shape. Entry kept (not renumbered) to preserve the audit trail and stable cross-references.

  • #3 — JWT_SECRET hardcoded across 7 files. RESOLVED by fix-auth-bypass Brief 1 (commit 4a10dce). lib/auth-secret.js is now the single source of truth and throws at module load when JWT_SECRET is unset. Canonical TTL is JWT_TOKEN_TTL = '24h'. The 'your-secret-key-change-in-production' literal is gone from all 7 sites; CI lint passes against the post-fix tree. Entry kept (not renumbered) to preserve cross-references.

  • #4 — Default admin credentials in the seed. RESOLVED by drop-public-setup Brief 1 (commit ff80753) + Brief 2 (commit b63b509). scripts/setup-neon-db.js no longer hardcodes admin123; it reads ADMIN_INITIAL_PASSWORD from the environment and exits with code 1 before opening a DB connection if the var is unset. README's "Default Admin Account" section is replaced with "First-time admin setup" copy that documents the env var, openssl rand -base64 24 generation tip, and CI-secret alternative. Brief 2 converted the script from CJS to ESM so npm run setup-db actually runs on Node 22.x (the bump-next-js convoy's "type": "module" flag had silently broken it). Operator caveat: the seed is idempotent (ON CONFLICT (email) DO NOTHING); re-running setup-db on an env that already has the admin row does NOT rotate the password. Any deployed env that ran setup before this convoy still has the weak admin123 hash — operators must rotate manually via the app, or wait for the queued rotate-default-admin follow-up convoy. Entry kept (not renumbered) to preserve cross-references.

  • #5 — pages/api/setup-database.js public endpoint. RESOLVED by fix-auth-bypass Brief 3 (commit fc0dd73). The file is deleted along with the other three dev endpoints (/api/simple, /api/test-auth, /api/test-db), and .github/workflows/ci.yml's new forbidden-endpoints job fails the build if any of them are re-introduced (or if a new pages/api/test-*.js file appears). Entry kept (not renumbered) to preserve cross-references.

  • #6 — Migrations are bare scripts. scripts/add-*.js and scripts/fix-*.js are run-once jobs with no idempotency tracking. Adopt node-pg-migrate, kysely, or drizzle-kit before more schema changes.

  • #7 — Dual is_public semantics. Collections and decks both have is_public columns; check which controls discovery vs. anonymous read in the relevant route.

  • #8 — Layout has hardcoded default user. RESOLVED by fix-layout-default-user convoy (PR #15, squash commit ca302a8). components/Layout.js's default prop is now null; UserProfileDropdown renders a <Link href="/login">Sign in</Link> CTA when user === null. Brief 2 also swept the 7 pages that needed page-level fixes (scanner / decks / deck-builder / deck/[id] now pass user={user} to Layout; profile / settings replaced leaky useState({email:'me@…'}) with useState(null) + null-guards on every sync user.* read; card/[id] swapped a hardcoded const user = {...} for useAuth() from lib/use-auth.js). test/components/Layout.test.js adds 5 regression-lock assertions (no maintainer email when user is null/omitted; "Sign in" link present; supplied email renders; no "Guest" placeholder); vitest 21/21 green at merge. New devDeps: jsdom@^29 + @testing-library/react@^16. See .convoys/fix-layout-default-user.md and .convoys/ship-readiness.md P0 #7. Entry kept (not renumbered) to preserve cross-references.

  • #9 — typescript is a devDep, but the source is still JavaScript-only. package.json lists typescript@^5.9.3 purely so eslint-config-next@16's bundled typescript-eslint chain can satisfy its hard require('typescript') at module load (the peerDependenciesMeta.typescript.optional: true flag in eslint-config-next only suppresses npm's install-time warning, not the runtime require). There is no tsconfig.json, no .ts/.tsx files, and no // @ts-check directives. Do not rename .js files to .ts or add a tsconfig.json without an explicit convoy decision — TypeScript adoption is its own scope. See .convoys/bump-next-js.md § Decisions C.

  • #10 — ESLint pinned to v9 (maintenance), not v10 (latest). devDependencies.eslint is ^9.39.4 even though latest is 10.4.0. We tried v10 and npm run lint crashed with TypeError: scopeManager.addGlobals is not a function because eslint-config-next@16's bundled typescript-eslint@8.x predates ESLint v10's redesigned global-ingestion path. Reverted to v9 under Decision D. Do NOT bump ESLint independently — wait for the queued bump-eslint-10 follow-up convoy, which is upstream-blocked until typescript-eslint ships a v10-tested release that eslint-config-next bundles. See .convoys/bump-next-js.md § Decisions D + "Follow-up convoys queued".

  • #11 — Turbopack is now the default bundler. next dev and next build use Turbopack by default in Next.js 16. The fallback per command is --webpack (e.g. next build --webpack). We have no custom webpack: block in next.config.js, no custom loaders/aliases, and no Sass tilde imports, so Turbopack should "just work" — but if a build/runtime regression appears, reproduce on both bundlers before deciding whether to revert or pin a script to webpack. Do not pre-emptively switch to --webpack.

  • #12 — Rate-limit env vars are KV_REST_API_URL / KV_REST_API_TOKEN, not UPSTASH_REDIS_REST_*. lib/rate-limit.js reads the Vercel Upstash Marketplace integration's auto-provisioned names. Three other Upstash-shaped vars exist in the Vercel-managed env (KV_URL, REDIS_URL, KV_REST_API_READ_ONLY_TOKEN) but our @upstash/redis REST client does not use them — do not wire to them. In prod, the rate-limit module fails closed if either of the two REST vars is missing (a single failed login is a better outcome than silently disabling brute-force protection). In dev / test, it warn-and-continues as a no-op so local work is unaffected when Upstash isn't wired up.

    Milestone — add-rate-limiting convoy (squash 708ef45, PR #20, 2026-05-24) closed P0 #6 — all 8 P0s now RESOLVED. The lib refactored from a single auth-only limiter to 5 named limiters with a Map<className, Ratelimit> cache (one shared Redis client, five Ratelimit instances, distinct Redis prefix per class). The five exports + their use cases:

    Helper Class Limit/window Key Routes
    checkAuthRateLimit(req) auth 5 / 15 min IP /api/auth/login, /api/auth/register (Brief 4 contract; byte-identical return shape preserved)
    checkSearchRateLimit(req) search 60 / 1 min IP /api/users/search, /api/cards/search
    checkUploadRateLimit(req, userId) upload 10 / 1 hour user /api/user/avatar
    checkGenerateRateLimit(req, userId) generate 5 / 1 hour user /api/user/avatar/generate
    checkImportRateLimit(req, userId) import 5 / 1 hour user (admin-only) /api/cards/import-mtg, /api/cards/import-pokemon, /api/cards/import-lorcana

    All five return the same { allowed, remaining, reset } shape; on !allowed, set Retry-After: Math.ceil((reset - Date.now()) / 1000) and return 429 with the uniform message 'Too many attempts. Try again later.' (per-class variation would fingerprint the limits to an attacker — explicitly rejected).

    Defensive THROW pattern. extractUserIdentifier(userId) THROWS with a named error when userId is null / undefined / '' / NaN. Surfaces gate-ordering bugs at dev time rather than silently falling back to IP and converting a per-user limit into a per-IP limit (which would lock household members out for one user's behavior). Numeric 0 is intentionally accepted (returns 'user:0') for forward-compat. Gate-ordering rule: per-user rate-limit gates (upload, generate, import) MUST sit AFTER the auth check. For the three /api/cards/import-* routes, the ordering is also auth → admin-role check (403 if not admin) → rate-limit; the admin-role check sits between auth and rate-limit. IP-keyed gates (auth, search) can sit anywhere after the method check.

    Adding a sixth class is a one-line LIMITER_CONFIG addition + one new exported function (no init() restructuring needed). Tuning an existing class is a one-line LIMITER_CONFIG edit. The full verbatim call shape + gate-ordering rules + identifier-extraction documentation live in .cursor/rules/api-routes.mdc § Rate limiting.

5. Running locally

  • Runtime: Node 20 (Vercel default).
  • Setup: npm install, copy .env.local template (POSTGRES_URL + JWT_SECRET + RESEND_API_KEY + BLOB_READ_WRITE_TOKEN + ADMIN_INITIAL_PASSWORD — the last is required for npm run setup-db and the script exits with code 1 if it's unset; optionally KV_REST_API_URL + KV_REST_API_TOKEN to exercise the rate limiter locally — without them, lib/rate-limit.js warn-and-no-ops in dev), then npm run setup-db once.
  • Dev server: npm run devhttp://localhost:3000.

6. Testing

  • Unit-test runner: vitest@^3.2.4 (installed via fix-auth-bypass Brief 5, commit 1629afb). npm test for watch mode; npm run test:run for the CI / single-shot mode. Config in vitest.config.js, setup in test/setup.js (sets JWT_SECRET + NODE_ENV=test before any module loads). Specs live under test/ mirroring source layout (test/lib/*.test.js, test/api/*.test.js, test/components/*.test.js). Last green: 21/21 tests pass.

  • Vitest coverage today: 21 unit tests — lib/auth-secret.js (3), lib/permission-middleware.js::getUserFromRequest (8, incl. a negative regression against the old synthetic-admin shape — Gotcha #2), pages/api/auth-utils.js (5), and components/Layout.js (5 regression-lock assertions for the post-PR-#15 logged-out branch — Gotcha #8). These tests lock in the contracts established by fix-auth-bypass Briefs 1 + 2 and fix-layout-default-user; do not weaken them when refactoring auth or Layout.

  • E2E / smoke runner: @playwright/test@^1.60.0 (installed via adopt-playwright-smoke, PR #18 squash 7b6f751). Config in playwright.config.js (root, ESM) declares two projects:

    • smoketests/smoke/**/*.spec.@(ts|js); invoked by .github/workflows/preview-smoke.yml. npm run test:smoke locally.
    • visualtests/visual/**/*.spec.@(ts|js); invoked by .github/workflows/visual-diff.yml. npm run test:visual locally; npm run test:visual:update to (re-)seed baselines.

    Local-run convention: boot next dev in one terminal, then in another run BASE_URL=http://localhost:3000 npm run test:smoke (or against a deployed preview, BASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value> npm run test:smoke). No next dev auto-boot in the test scripts (Decision 6 of adopt-playwright-smoke).

  • Browsers must be installed once locally: npx playwright install --with-deps chromium. CI re-runs this on every workflow run (it's cached when possible).

  • Visual baselines: none committed yet. tests/visual/__screenshots__/ is intentionally absent and intentionally NOT in .gitignore (baselines, when they exist, must be committed). First-run baseline generation MUST happen in a Linux environment so the PNG matches what CI produces. Recommended path is the Playwright Docker image:

    docker run --rm -v "$PWD":/work -w /work \
      mcr.microsoft.com/playwright:v1.60.0-noble \
      sh -c "npm ci && BASE_URL=<preview-url> \
        VERCEL_AUTOMATION_BYPASS_SECRET=<value> \
        npm run test:visual:update"
    

    Mac-generated baselines will NOT match Linux CI — playwright.config.js's custom snapshotPathTemplate has no {platform} token, so a Mac update silently overwrites the canonical Linux baseline. Tracked as the queued seed-visual-baselines-on-linux convoy (see .convoys/ship-readiness.md § Queued convoys).

  • CI behavior:

    • Vitest: the test: job in .github/workflows/ci.yml runs npm run test:run on every PR and push to main and is blocking (no || true, no continue-on-error). A red test job blocks merge.
    • Playwright smoke: runs on every PR via preview-smoke.yml. Gate skip via pipeline: skip smoke in the PR body (handled in the gate: job's Decide step via env-var routing — see § 7's shell-injection note). Last measured runtime: 59s end-to-end, 3/3 tests pass in 2.9s (PR #18 post-merge run).
    • Screenshot diff: runs only on PRs touching pages/** / components/** / styles/** / tailwind.config.js / postcss.config.js via visual-diff.yml. First Screenshot diff run after adopt-playwright-smoke will fail at the test step because no baseline exists yet; continue-on-error: true swallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state of adopt-playwright-smoke, not a regression — it stays that way until seed-visual-baselines-on-linux lands.
  • Manual QA: TESTING_GUIDE.md still applies for flows not yet covered by automated tests (scanner camera path, card-import jobs, multi-step UI wizards). The automated smoke + visual suite is steadily eclipsing it; TESTING_GUIDE.md will be renamed to docs/MANUAL_QA.md and trimmed to truly-manual-only flows in a future cleanup convoy (see .convoys/ship-readiness.md § Role-doc-writer findings).

7. Deployment

  • Vercel auto-deploys main and creates Preview deployments for every PR. vercel.json and .vercel/ are committed. CI in .github/workflows/ runs lint + types (no duplicate build — Vercel handles it).

  • Preview protection bypass for automation. The project has a Protection Bypass for Automation token exposed locally as VERCEL_AUTOMATION_BYPASS_SECRET in .env.local (not committed) and seeded into GitHub Actions as a repo secret (gh secret set VERCEL_AUTOMATION_BYPASS_SECRET, 2026-05-24). The secret is consumed in two shapes:

    1. Query parameter on wait-for-vercel-preview@v1.3.2's path: input in both preview-smoke.yml and visual-diff.ymlpath: '/?x-vercel-protection-bypass=…', bare form, without &x-vercel-set-bypass-cookie=true (the cookie variant returns 307 + Set-Cookie and axios in Node has no cookie jar, so it 401s on the redirect). Plumbed by PR #17 (fix-vercel-deployment-protection-in-ci, squash 9a3e077).
    2. HTTP header in playwright.config.js's use.extraHTTPHeaders'x-vercel-protection-bypass': <secret>. Playwright's browser context has a real cookie jar so this shape works there, and the testOptions surface forwards the header to the test-level request fixture's APIRequestContext as well, so both page.goto(...) calls and request.get('/api/health') calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (adopt-playwright-smoke, squash 7b6f751) per Decision 2 of that convoy.

    Decision 2 also wires a fail-loud-in-CI / warn-in-dev predicate: if (process.env.CI === 'true' && !process.env.VERCEL_AUTOMATION_BYPASS_SECRET) throw ... (with an error message that names the env var, the gh secret set rotation command, and points at this section); otherwise console.warn once and continue with extraHTTPHeaders undefined. Same fail-closed / warn-and-no-op shape as lib/rate-limit.js's Upstash predicate — see Gotcha #12.

    Do not log or echo the value. If the operator rotates the token in the Vercel dashboard, re-seed the GitHub secret via gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>". See .convoys/fix-vercel-deployment-protection-in-ci.md and .convoys/adopt-playwright-smoke.md.

  • Shell-injection hardening in workflow YAML. Never inline ${{ github.event.* }} directly into a run: block — route the value through the step's env: block and quote it ("$VAR_NAME") in shell. PR #17's CI validation caught a real syntax error from a PR body containing ( because the gate-job's Decide step inlined ${{ github.event.pull_request.body }} straight into bash; commit b6f8688 swept both preview-smoke.yml and visual-diff.yml to the env: + quoted-shell pattern. This is GitHub's official Security Hardening guidance ("Security hardening for GitHub Actions" → "Using a third-party action"). Apply to any new workflow that reads PR body / title / branch name / commit messages in shell.

8. Code graph

A local code-knowledge-graph MCP server (user-code-review-graph) is set up for this repo. Ask "what calls X?" or "show me the flow from /api/auth/login" instead of grepping. See docs/agent-context/README.md.