Standalone infrastructure PR (no convoy ceremony needed — single-file scope). Triggered by the GitHub Actions billing block that gated PRs #124 + #125 today. Three layers of savings applied per the user's max-savings option: 1. paths-ignore on ci.yml + preview-smoke.yml - Doc-only PRs (.convoys/**, **/*.md, docs/**, AGENTS.md, .cursor/**, README.md) now trigger ZERO Actions jobs. - Vercel still builds (it's not on the Actions billing). - visual-diff.yml unchanged — it was already cost-conscious via a positive paths: allowlist (pages/**, components/**, styles/**, etc.). 2. Consolidate 6 grep-only forbidden-* jobs into 1 - Previously 6 independent jobs each ran their own actions/checkout (~3s × 6 = 18s of redundant checkout). - Merged into a single forbidden-patterns job with 6 sequential ::group:: sections, one FAIL flag at the bottom — preserves "see all violations in one run" diagnostic behavior. Per-file ::error file=...::msg annotations work the same way. - Removed jobs: forbidden-endpoints, forbidden-cors-headers, forbidden-client-side-llm-keys, forbidden-modal-shell-without-primitive, forbidden-deprecated-color-aliases, forbidden-stale-strings. - pr-health-rollup.yml only looks up "Lint" and "Schema map up to date" by name — unaffected. 3. Cache node_modules + Playwright browsers - actions/cache@v4 for node_modules keyed by package-lock.json hash, applied to lint / test / migrate / preview-smoke / visual-diff. Cuts npm ci from ~30-45s to ~3-5s on cache hit. setup-node@v4's built-in cache: npm stays (caches ~/.npm) — both layered. - actions/cache@v4 for ~/.cache/ms-playwright keyed by the resolved @playwright/test version. Cache invalidates on any Playwright version bump. On cache hit, only system deps install runs (npx playwright install-deps chromium) — saves ~15-25s/run. AGENTS.md updates: - § 6 Testing § CI behavior: appended "CI minute optimizations" subsection documenting all three layers. - § Product vocabulary table caption: updated "CI job forbidden-stale-strings" reference to "CI check Forbidden patterns (6 checks) → Check 6/6" with a historical pointer. - Gotcha #5: updated the standalone forbidden-endpoints reference similarly. Estimated savings per typical convoy mix (~30% doc PRs based on repo history): - Doc-only PRs: 100% reduction (was ~6-7 min, now 0 Actions min). - Code-touching PRs: ~30-50% reduction (cache hits for npm + Playwright + no redundant 6× checkout). - Weighted average: ~50-60% reduction. This is short of the 70-80% I floated in chat — the real ceiling is limited by lint / vitest / migrate / Playwright runtime itself, all of which are kept on code-touching PRs (they're high-signal). Verification: - All 3 workflow YAMLs parse (python3 -c "yaml.safe_load(...)"). - npm run lint passes (1 pre-existing unrelated warning). - npm run test:run: 118/118 tests pass. - forbidden-patterns logic is byte-equivalent to the 6 original jobs' bash bodies — the differences are: per-check ::group::/::endgroup:: framing, a shared FAIL flag instead of per-job exit 1, and renamed local arrays (FOUND → LLM_FOUND / MODAL_FOUND / STRING_FOUND) to avoid clobbering across the single job's scope. Co-authored-by: Cursor <cursoragent@cursor.com>
33 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.
Product vocabulary
User-facing copy distinguishes ownership (everything you own) from curated lists (binders/subsets). Import labels from lib/collection-vocabulary.js (VOCAB, collectionDisplayName) rather than hardcoding strings.
| Concept | UI label | Route / schema | Notes |
|---|---|---|---|
| Global ownership | My Collection | /my-cards, user_cards |
Scanner default destination; replaces "Owned" / "Mark Owned" |
| Curated list / binder | List / Lists | /collections, /collection/[id], collections table |
URL slug unchanged in v1; nav says "Lists" |
| Auto-sync system list | Synced binder (display) | collections row with is_system_collection = true |
DB name stays 'All My Cards' — never render; use collectionDisplayName() |
| Add ownership | Add to My Collection | POST /api/user-cards, scanner bulk |
Replaces "Mark Owned" / "Mark as Owned" |
| Add to curated list | Add to List | POST /api/collections/:id/cards |
Replaces "Add to Collection" in scanner/card flows |
CI check Forbidden patterns (6 checks) → Check 6/6 blocks "Mark Owned", "Owned Cards", and "All My Cards" in pages/ + components/ (API literals exempt). [Formerly the standalone forbidden-stale-strings job; merged into forbidden-patterns by the slash-ci-minutes convoy on 2026-06-04.]
Visual language
Deck Hearth's visual direction is Liquid Glass (in-progress as of
2026-06-03 — see .convoys/liquid-glass-redesign.md umbrella). Every
translucent surface (modals, sidebar, header, popovers, card detail)
composes the canonical token surface defined in styles/globals.css
and documented in docs/DESIGN_TOKENS.md.
Do not hardcode hex in .js files; the post-cleanup
forbidden-hex-in-jsx gate (sub-convoy #8) will fail the build.
Three rules of thumb:
- Surfaces are glass. Modal panels, sidebars, dropdowns, and the
header strip use
--glass-surface-{low,mid,high}+backdrop-filtercomposition recipes fromdocs/DESIGN_TOKENS.md§ "Composite recipes". - Brand warmth is accent, not panel fill. Ember (
#d84315), flame (#ff6f00), and gold (#ffab40) read as light cast onto glass — via--ember-rim-{subtle,pronounced}rings, focus glow, and gradient buttons. They are NOT the canonical panel-background color. - No
backdrop-filteron card grid items. GPU budget — glass goes on grid containers and detail views, not per-card. Seedocs/DESIGN_TOKENS.md§ "Per-card grid performance budget".
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. The runtime auth surface uses
@vercel/postgrestagged-template SQL exclusively post-single-sql-client(PR #30,c403ea4;lib/database.jsdeleted). 11scripts/**helpers (setup-neon-db.js,reset-db.js,migrations/2026-05-24-rename-admin-email.js, plus 8 historical add-/fix-/seed-* jobs) still use@neondatabase/serverless'sneon()directly — out-of-scope per the no-go-zones rule and tracked as the queuedpurge-neondatabase-serverless-fullyfollow-up. Schema changes ship asnode-pg-migratemigrations undermigrations/at the repo root post-migration-tool(PR #32,de9f334) — see § 3 Conventions § "Schema changes" and Gotcha #6. - 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 |
use-auth (canonical client hook — sole surface post-single-auth-provider, PR #31, 0668b0c), auth-secret (single JWT_SECRET + TTL source), permission-middleware (server-side getUserFromRequest + withCollectionPermission), rate-limit (5 named limiters — see Gotcha #12). The legacy lib/database.js was deleted by single-sql-client (PR #30, c403ea4); DB access now goes through @vercel/postgres tagged templates directly. |
| 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'is the only client auth surface. Returns{ user, loading, logout, refreshAuth };user === nullmeans logged out,loading === truemeans token verification in flight. There is no client-side admin hook — computeconst isAdmin = user?.role === 'admin'from the sameuseAuth()call. The legacylib/auth-context.js+lib/admin-auth.jswere deleted bysingle-auth-provider(PR #31,0668b0c); do not reintroduce a<AuthProvider>/<AdminProvider>wrapper inpages/_app.js. The login + signup flow uses directfetch('/api/auth/{login,register}')frompages/login.js/pages/signup.js— there is nouseAuth().login(...)/useAuth().register(...)method; do not add one. - 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'. The legacylib/database.js(db.query(string, params)wrapper around@neondatabase/serverless, which interpolated params into a string and calledsql.unsafe) was deleted bysingle-sql-client(PR #30,c403ea4); do NOT reintroduce that shape. Forscripts/**helpers that legitimately need the Neon HTTP driver (e.g.setup-neon-db.js,reset-db.js), import{ neon } from '@neondatabase/serverless'directly and use tagged-template SQL (await sql\...``) — the safe shape, not the wrapper's unsafe shape. - 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. - Schema changes (post-
migration-tool): new column / constraint / table work ships as anode-pg-migratemigration undermigrations/at the repo root. Generate vianpm run migrate create <name> -- -j js, edit theup()(anddown()when rollback is safe — for any migration that touches data, prefer a hard-stubdown()that throws), and apply locally withnpm run migrate up.npm run setup-dbnow chainsnpm run migrate upthen seeds the admin user. The legacyscripts/add-*.js/scripts/fix-*.js/scripts/seed-*.jsjobs are append-only history (no-go-zones rule); do NOT add new ones. See.convoys/migration-tool.mdfor the full architect-decision record and Gotcha #6 below for the historical context.
4. Common gotchas
-
#1 — Two SQL clients live in parallel. RESOLVED by
single-sql-clientconvoy (PR #30, squash commitc403ea4, 2026-05-26).lib/database.jsis deleted; the 2 callers (pages/api/auth-utils.jssource +test/api/auth-utils.test.jsmock) migrated to@vercel/postgrestagged templates (byte-equivalent SQL semantics for the two single-parameter SELECT queries). The convoy's architect audit (D4) confirmed no current call site actually exercised thesql.unsafeinjection vector — theuserIdcallers passed a numeric SERIAL from a verified JWT — so this was foot-gun removal rather than a live security finding.@neondatabase/serverlessis still inpackage.jsonas a runtime dep because 11scripts/*helpers continue to useneon()directly (setup-neon-db.js,migrations/2026-05-24-rename-admin-email.js,reset-db.js, plus 8 historicaladd-*/fix-*/seed-*jobs). Those scripts use the safe tagged-template shape (await sql\...`), not the deleted wrapper's unsafedb.query(string, params)shape. Full dep purge is tracked as the queuedpurge-neondatabase-serverless-fullyfollow-up (now unblocked bymigration-toolPR #32 — the migration helpers all usenode-pg-migrate'spgclient, not@neondatabase/serverless, so the only remaining directneon()consumers aresetup-neon-db.js(admin seed),reset-db.js`, and the historical graveyard). Entry kept (not renumbered) to preserve cross-references. -
#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'sforbidden-patternsjob (formerly the standaloneforbidden-endpointsjob; consolidated byslash-ci-minutesconvoy on 2026-06-04) 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 were bare scripts. RESOLVED by
migration-toolconvoy (2026-05-26).node-pg-migrate@^8is the chosen tool (lightweight, raw-SQL-friendly, zero TS surface — matches the repo's JavaScript-only@vercel/postgresstyle). New migrations live undermigrations/at the repo root and use the defaultpgmigrationstracking table. The initial backfillmigrations/1779853647564_initial-schema.jsreproducesscripts/setup-neon-db.js's 7-table DDL verbatim usingCREATE TABLE IF NOT EXISTS, so it's idempotent against fresh AND pre-existing envs — first-timenpm run migrate upon an env that already ransetup-neon-db.jspre-convoy is a no-op DDL-wise (only records thepgmigrationsrow). The legacy 27scripts/add-*.js/scripts/fix-*.js/scripts/seed-*.jsjobs are append-only history per the no-go-zones rule — do NOT add new ones. New column / constraint work ships as anode-pg-migratemigration. See § 3 Conventions § "Schema changes" above +.convoys/migration-tool.md. Entry kept (not renumbered) to preserve cross-references. -
#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-pokemonPrefixes 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 two/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
-
CI minute optimizations (slash-ci-minutes convoy, 2026-06-04):
- Doc-only PRs skip ALL of ci.yml + preview-smoke.yml. Both workflows carry
paths-ignorefor.convoys/**,**/*.md,docs/**,AGENTS.md,.cursor/**, andREADME.md. A pure-docs PR triggers zero GitHub Actions jobs (Vercel still builds — it's not on the Actions billing).visual-diff.ymlwas already cost-conscious via a positivepaths:allowlist and is unchanged. - The 6 grep-only forbidden- jobs collapsed into one.* They previously ran as 6 independent jobs (each with its own
actions/checkout); the consolidatedforbidden-patternsjob runs all 6 checks as labeled::group::sections in a single bash step, with a FAIL flag at the bottom so every violation across all 6 checks still surfaces in one run (same diagnostic behavior, ~5/6 of the per-PR checkout overhead removed). The 6 original job names (forbidden-endpoints,forbidden-cors-headers,forbidden-client-side-llm-keys,forbidden-modal-shell-without-primitive,forbidden-deprecated-color-aliases,forbidden-stale-strings) no longer appear in the checks list — references in this file (e.g. CI jobforbidden-stale-stringsblocks ...) are now informational, not check-name lookups.pr-health-rollup.ymlwas unaffected because it only looks upLintandSchema map up to dateby name. node_modulescached between runs in lint / test / migrate / preview-smoke / visual-diff. Keyed onpackage-lock.jsonhash so any dep change invalidates correctly. Cutsnpm cifrom ~30-45s to ~3-5s on cache hit.actions/setup-node@v4's built-incache: npmis layered above this (caches~/.npm) — both stay because thesetup-nodecache helps on cache-miss days too.- Playwright browsers cached in
preview-smoke.yml+visual-diff.yml. Keyed on the resolved@playwright/testversion frompackage-lock.json. Cache invalidates automatically on any Playwright version bump. On cache hit, only the system deps install (npx playwright install-deps chromium) runs — saves ~15-25s/run.
- Doc-only PRs skip ALL of ci.yml + preview-smoke.yml. Both workflows carry
-
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.