Post-merge doc cleanup for PR #21. Resolves the AGENTS.md line-5 'Pick one before launch' branding question. Updates AGENTS.md top branding note + Gotcha #4 + Gotcha #12, adds full as-shipped to .convoys/pick-a-name.md, adds 4 queued-convoy entries to ship-readiness.md, adds pick-a-name attribution to .cursor/rules/api-routes.mdc. Pure docs (+357/-15 across 4 files).
24 KiB
AGENTS.md — AI collaboration (tcg-vault)
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
Branding note: this product is Deck Hearth as of 2026-05-24 (
pick-a-nameconvoy, squash commit9abbab6, PR #21). The repo and Vercel project are still namedtcg-vault— that rename is tracked in the queuedrename-repo-and-vercel-projectconvoy (auto-redirects make it low-urgency). Admin email isadmin@deckhearth.com; the prioradmin@tcgvault.comliteral is deliberately preserved intest/lib/permission-middleware.test.jsas a historical regression-lock per Risk 4 of the pick-a-name convoy.
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 asAuthorization: Bearer …. No NextAuth. The secret + canonical 24h TTL come fromlib/auth-secret.js(single source of truth; throws at module load ifJWT_SECRETis unset).getUserFromRequestreturnsnullfor 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 atadmin@deckhearth.comwith a password supplied via the requiredADMIN_INITIAL_PASSWORDenv var (scripts/setup-neon-db.jsexits 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 thedrop-public-setupconvoy still have the oldadmin123hash 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 }ornull.nullmeans "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'. Avoidlib/auth-context.jsandlib/admin-auth.jsfor new code — they are legacy parallel implementations. - Layout
userprop: pages should passuserfromuseAuth()to<Layout>. Layout's default isnulland renders a logged-out "Sign in" CTA when no user is supplied — both paths are valid (some surfaces likepages/invite/{accept,decline}.jslegitimately 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 fromlib/auth-secret.jsunder the hood. - Rate limiting:
import { checkAuthRateLimit } from '../../lib/rate-limit.js'for any new auth-surface endpoint (/api/auth/login+/api/auth/registeralready wired). Returns{ allowed, remaining, reset }; on!allowedreturn 429 with aRetry-Afterheader. See.cursor/rules/api-routes.mdc§ "Rate limiting" for the verbatim shape. - Permission gate for collection routes: wrap handlers with
withCollectionPermission('viewer' | 'editor' | 'owner')fromlib/permission-middleware.js. - DB access: Use tagged-template style —
import { sql } from '@vercel/postgres'. Avoid the legacylib/database.jsdb.query(string, params)API; its parameter interpolation usessql.unsafeand 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.jsfor libs/scripts;PascalCase.jsfor React components. - Imports: No path aliases configured; use relative imports.
- Slugs:
lib/slug-utils.js::generateUniqueSlugfor any user-facing identifier (collections, decks). - CSS theme tokens: Components read
var(--bg-primary),var(--text-primary),var(--accent-ember), etc. — defined instyles/. Don't hardcode hex colors.
4. Common gotchas
-
#1 — Two SQL clients live in parallel.
@neondatabase/serverless(used bylib/database.js) and@vercel/postgres(used by mostpages/api/**handlers). New code: prefer@vercel/postgrestagged templates. Migration to a single client is tracked in.convoys/. -
#2 —
getUserFromRequestsynthetic-admin fallback. RESOLVED byfix-auth-bypassBrief 2 (commit258e479). The helper now returnsnullfor unauthenticated requests;pages/api/auth/verify.jsreturns 401 on the no-token branch. The 16 unit tests intest/lib/permission-middleware.test.jslock 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-bypassBrief 1 (commit4a10dce).lib/auth-secret.jsis now the single source of truth and throws at module load whenJWT_SECRETis unset. Canonical TTL isJWT_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-setupBrief 1 (commitff80753) + Brief 2 (commitb63b509).scripts/setup-neon-db.jsno longer hardcodesadmin123; it readsADMIN_INITIAL_PASSWORDfrom 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 24generation tip, and CI-secret alternative. Brief 2 converted the script from CJS to ESM sonpm run setup-dbactually runs on Node 22.x (thebump-next-jsconvoy'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 weakadmin123hash — operators must rotate manually via the app, or wait for the queuedrotate-default-adminfollow-up convoy. Entry kept (not renumbered) to preserve cross-references.Post-
pick-a-name(2026-05-24, squash9abbab6), the seeded admin email isadmin@deckhearth.com(and alice/bob test users likewise renamed). If you are deploying past9abbab6and the prod Neon DB still has@tcgvault.comrows, you MUST runnode scripts/migrations/2026-05-24-rename-admin-email.jsBEFORE the next admin login attempt or it 401s. The migration is ESM, idempotent, UNIQUE-collision-safe (fails loud ifsetup-neon-db.jsalready ran post-rename — which would indicate an ordering error). Order: migration FIRST, then any subsequentnpm run setup-db. -
#5 —
pages/api/setup-database.jspublic endpoint. RESOLVED byfix-auth-bypassBrief 3 (commitfc0dd73). 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 newforbidden-endpointsjob fails the build if any of them are re-introduced (or if a newpages/api/test-*.jsfile appears). Entry kept (not renumbered) to preserve cross-references. -
#6 — Migrations are bare scripts.
scripts/add-*.jsandscripts/fix-*.jsare run-once jobs with no idempotency tracking. Adoptnode-pg-migrate,kysely, ordrizzle-kitbefore more schema changes. -
#7 — Dual
is_publicsemantics. Collections and decks both haveis_publiccolumns; check which controls discovery vs. anonymous read in the relevant route. -
#8 — Layout has hardcoded default user. RESOLVED by
fix-layout-default-userconvoy (PR #15, squash commitca302a8).components/Layout.js's default prop is nownull;UserProfileDropdownrenders a<Link href="/login">Sign in</Link>CTA whenuser === null. Brief 2 also swept the 7 pages that needed page-level fixes (scanner/decks/deck-builder/deck/[id]now passuser={user}to Layout;profile/settingsreplaced leakyuseState({email:'me@…'})withuseState(null)+ null-guards on every syncuser.*read;card/[id]swapped a hardcodedconst user = {...}foruseAuth()fromlib/use-auth.js).test/components/Layout.test.jsadds 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.mdand.convoys/ship-readiness.mdP0 #7. Entry kept (not renumbered) to preserve cross-references. -
#9 —
typescriptis a devDep, but the source is still JavaScript-only.package.jsonliststypescript@^5.9.3purely soeslint-config-next@16's bundledtypescript-eslintchain can satisfy its hardrequire('typescript')at module load (thepeerDependenciesMeta.typescript.optional: trueflag ineslint-config-nextonly suppresses npm's install-time warning, not the runtime require). There is notsconfig.json, no.ts/.tsxfiles, and no// @ts-checkdirectives. Do not rename.jsfiles to.tsor add atsconfig.jsonwithout 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.eslintis^9.39.4even thoughlatestis10.4.0. We tried v10 andnpm run lintcrashed withTypeError: scopeManager.addGlobals is not a functionbecauseeslint-config-next@16's bundledtypescript-eslint@8.xpredates ESLint v10's redesigned global-ingestion path. Reverted to v9 under Decision D. Do NOT bump ESLint independently — wait for the queuedbump-eslint-10follow-up convoy, which is upstream-blocked untiltypescript-eslintships a v10-tested release thateslint-config-nextbundles. See.convoys/bump-next-js.md§ Decisions D + "Follow-up convoys queued". -
#11 — Turbopack is now the default bundler.
next devandnext builduse Turbopack by default in Next.js 16. The fallback per command is--webpack(e.g.next build --webpack). We have no customwebpack:block innext.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, notUPSTASH_REDIS_REST_*.lib/rate-limit.jsreads 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/redisREST 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-limitingconvoy (squash708ef45, 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 aMap<className, Ratelimit>cache (one shared Redis client, fiveRatelimitinstances, distinct Redis prefix per class). The five exports + their use cases:Helper Class Limit/window Key Redis prefix Routes checkAuthRateLimit(req)auth5 / 15 min IP deckhearth:auth/api/auth/login,/api/auth/register(Brief 4 contract; byte-identical return shape preserved)checkSearchRateLimit(req)search60 / 1 min IP deckhearth:search/api/users/search,/api/cards/searchcheckUploadRateLimit(req, userId)upload10 / 1 hour user deckhearth:upload/api/user/avatarcheckGenerateRateLimit(req, userId)generate5 / 1 hour user deckhearth:generate/api/user/avatar/generatecheckImportRateLimit(req, userId)import5 / 1 hour user (admin-only) deckhearth:import/api/cards/import-mtg,/api/cards/import-pokemon,/api/cards/import-lorcanaPrefixes renamed
tcgvault:*→deckhearth:*inpick-a-name(squash9abbab6, 2026-05-24); accepted one-time per-15-min / per-1-hour counter reset; existing Upstash state attcgvault:*keys is now stale and will TTL out naturally.All five return the same
{ allowed, remaining, reset }shape; on!allowed, setRetry-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 whenuserIdisnull/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). Numeric0is 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 alsoauth → 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_CONFIGaddition + one new exported function (noinit()restructuring needed). Tuning an existing class is a one-lineLIMITER_CONFIGedit. 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.localtemplate (POSTGRES_URL + JWT_SECRET + RESEND_API_KEY + BLOB_READ_WRITE_TOKEN + ADMIN_INITIAL_PASSWORD — the last is required fornpm run setup-dband 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.jswarn-and-no-ops in dev), thennpm run setup-dbonce. - Dev server:
npm run dev→ http://localhost:3000.
6. Testing
-
Unit-test runner:
vitest@^3.2.4(installed viafix-auth-bypassBrief 5, commit1629afb).npm testfor watch mode;npm run test:runfor the CI / single-shot mode. Config invitest.config.js, setup intest/setup.js(setsJWT_SECRET+NODE_ENV=testbefore any module loads). Specs live undertest/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), andcomponents/Layout.js(5 regression-lock assertions for the post-PR-#15 logged-out branch — Gotcha #8). These tests lock in the contracts established byfix-auth-bypassBriefs 1 + 2 andfix-layout-default-user; do not weaken them when refactoring auth or Layout. -
E2E / smoke runner:
@playwright/test@^1.60.0(installed viaadopt-playwright-smoke, PR #18 squash7b6f751). Config inplaywright.config.js(root, ESM) declares two projects:smoke—tests/smoke/**/*.spec.@(ts|js); invoked by.github/workflows/preview-smoke.yml.npm run test:smokelocally.visual—tests/visual/**/*.spec.@(ts|js); invoked by.github/workflows/visual-diff.yml.npm run test:visuallocally;npm run test:visual:updateto (re-)seed baselines.
Local-run convention: boot
next devin one terminal, then in another runBASE_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). Nonext devauto-boot in the test scripts (Decision 6 ofadopt-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 customsnapshotPathTemplatehas no{platform}token, so a Mac update silently overwrites the canonical Linux baseline. Tracked as the queuedseed-visual-baselines-on-linuxconvoy (see.convoys/ship-readiness.md§ Queued convoys). -
CI behavior:
- Vitest: the
test:job in.github/workflows/ci.ymlrunsnpm run test:runon every PR and push tomainand is blocking (no|| true, nocontinue-on-error). A red test job blocks merge. - Playwright smoke: runs on every PR via
preview-smoke.yml. Gate skip viapipeline: skip smokein the PR body (handled in thegate: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.jsviavisual-diff.yml. FirstScreenshot diffrun afteradopt-playwright-smokewill fail at the test step because no baseline exists yet;continue-on-error: trueswallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state ofadopt-playwright-smoke, not a regression — it stays that way untilseed-visual-baselines-on-linuxlands.
- Vitest: the
-
Manual QA:
TESTING_GUIDE.mdstill 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.mdwill be renamed todocs/MANUAL_QA.mdand 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
mainand creates Preview deployments for every PR.vercel.jsonand.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_SECRETin.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:- Query parameter on
wait-for-vercel-preview@v1.3.2'spath:input in bothpreview-smoke.ymlandvisual-diff.yml—path: '/?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, squash9a3e077). - HTTP header in
playwright.config.js'suse.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-levelrequestfixture'sAPIRequestContextas well, so bothpage.goto(...)calls andrequest.get('/api/health')calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (adopt-playwright-smoke, squash7b6f751) 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, thegh secret setrotation command, and points at this section); otherwiseconsole.warnonce and continue withextraHTTPHeadersundefined. Same fail-closed / warn-and-no-op shape aslib/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.mdand.convoys/adopt-playwright-smoke.md. - Query parameter on
-
Shell-injection hardening in workflow YAML. Never inline
${{ github.event.* }}directly into arun:block — route the value through the step'senv: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; commitb6f8688swept bothpreview-smoke.ymlandvisual-diff.ymlto theenv:+ 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.