Reflects the merged adopt-playwright-smoke convoy (PR #18, squash commit7b6f751) in repo documentation. Closes the test-infrastructure side of P1 #10 step 2 (launch sequence step 10). One commit in the convoy: Brief 1, with two small lint-baseline-preserving deviations from the brief's verbatim shape that the implementer report flagged. .convoys/adopt-playwright-smoke.md: - frontmatter status: in-progress -> shipped (added shipped: 2026-05-24) - new ## As-shipped section: operator-ratified Decisions (D1 keep .ts, D4 defer baselines, D6 simple scripts); two implementer deviations (removed unused eslint-disable-no-console directive that would have regressed lint 128 -> 129; placed @playwright/test first in devDeps for alphabetical correctness); cross-validation that smoke test 2 ("sign-in page renders") locks in PR #15's logged-out CTA work in components/Layout.js; empirical metrics from post-merge run 26376162598 (59s workflow, 3/3 in 2.9s, 0-leak); operator-action-required note pointing at the queued seed-visual-baselines-on-linux follow-up; What did NOT change audit trail. .convoys/ship-readiness.md: - Queued convoys: new entry seed-visual-baselines-on-linux (Linux- Docker baseline generation per Decision 4 + Boot-the-brief Finding 7; Mac-generated baselines would silently overwrite Linux CI baselines because the custom snapshotPathTemplate has no {platform} token). - Queued convoys: new RESOLVED block for adopt-playwright-smoke (PR #18,7b6f751) — as-shipped surface, implementer deviations, empirical metrics (59s workflow / 3/3 in 2.9s / 0 secret leaks), the PR #15 cross-validation finding, operator-action-required going forward (the seed-visual-baselines-on-linux follow-up), flagged-but-deferred items, and ownership trail (3 architect- self-ratified decisions + 3 operator-ratified). - Launch sequence step 10: marked RESOLVED 2026-05-24 with the commit + metrics inline. - P1 #10 No tests Fix sequence: step 2 marked RESOLVED with the convoy + metrics ref; step 3 (re-enable test: job in ci.yml) called out as the next remaining task; step 5 (wire preview-smoke.yml to the Vercel preview URL) marked RESOLVED across PR #17 + PR #18 since both contributed. AGENTS.md: - Section 6 Testing: rewritten end-to-end. Was "E2E/smoke runner still pending"; is now "@playwright/test@^1.60.0 wired, two projects (smoke + visual), npm run test:smoke / test:visual / test:visual:update". Documents the local-run convention (boot next dev separately, then BASE_URL=... npm run test:smoke); the one-time npx playwright install --with-deps chromium step; the no-baselines-yet state + the Linux-Docker seed command + the cross-platform mismatch reason (no {platform} token in snapshotPathTemplate); the CI behavior split (vitest blocking, smoke on every PR with pipeline:skip-smoke escape hatch, Screenshot diff path-filtered with the first-red-on-missing- baseline state documented). Updates vitest coverage count 16 -> 21 (the +5 Layout regression-lock tests from PR #15). Notes TESTING_GUIDE.md is being eclipsed and will be renamed to docs/MANUAL_QA.md in a future cleanup convoy. - Section 7 Deployment: rewrites the Vercel-bypass paragraph from a single "query param now / header reserved for future" bullet into a two-shape audit ((1) query param on the wait-action's path: input per PR #17; (2) HTTP header in playwright.config.js's use.extraHTTPHeaders per PR #18). Documents the Decision-2 fail-loud-in-CI / warn-in-dev predicate and references Gotcha #12 as the established precedent (lib/rate-limit.js). Picked Section 7 over a new Gotcha because the bypass plumbing is operationally a deployment concern, not an app pitfall. No changes to: package.json, package-lock.json, playwright.config.js, eslint.config.mjs, lib/**, pages/**, components/**, scripts/**, .github/workflows/**, .cursor/rules/**, README.md, tests/visual/homepage.spec.ts (JSDoc is already neutral-tense, no future-tense references to clean up). Co-authored-by: Cursor <cursoragent@cursor.com>
20 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 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@tcgvault.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. - #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.
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.