* convoy: forbidden-pattern gate + docs (briefs 3+4) Closes out the migrate-ci-to-self-hosted convoy with the two defensive follow-ups Brief 1+2 (PR #132) intentionally deferred. Brief 3 — Check 8 of `forbidden-patterns` in ci.yml. Greps `.github/workflows/` for `runs-on: ubuntu-latest` and fails unless the match is in the documented allowlist (currently `agent-context-drift.yml` only, per Decision D4). Self-tested locally against the post-migration tree: 0 violations. Renames the job from "Forbidden patterns (7 checks)" → "(8 checks)" and normalizes the older "Check N/6" labels to "N/8" for consistency (the inherited mix of `/6` and `/7` was a known cosmetic from the unify-glass-panel-surfaces convoy). Brief 4 — AGENTS.md § 6 and § 7 updates: - § 6 "CI behavior": Playwright smoke runtime range updated to cover post-migration cold vs. warm cache (was a stale 59s figure from pre-migration ubuntu-latest). - § 6 new top-level bullet "Self-hosted runner pool" alongside "CI minute optimizations" — covers where runners live, where caches are bind-mounted on CT 111, the Postgres rewire on CT 102, and the agent-context-drift.yml exemption + how Check 8 enforces it. - § 7 new bullet for the operational story: PAT rotation cadence + the D5 one-line `sed` revert path for when axiom is offline mid-PR-storm. Cross-references the axiom-server CT 111 README and the Beszel down alert. Convoy doc — status flipped queued → shipped, shipped_in lists both PRs, and follow_ups makes the cleanup-stale-ci-runs-cron + seed-visual-baselines-on-linux items machine-greppable. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop placeholder comment now PR #133 number is known Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
37 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). Pre-migration runtime: 59s end-to-end onubuntu-latest(PR #18 post-merge run). Post-migration on the axiom pool: cold-cache first run ~6 min (Chromium download); warm cache thereafter ~1–2 min. - 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
-
Self-hosted runner pool (migrate-ci-to-self-hosted convoy, 2026-06-05):
- 4 of 5 workflows run on the axiom homelab.
ci.yml,preview-smoke.yml,visual-diff.yml,pr-health-rollup.ymluseruns-on: [self-hosted, axiom]and execute on CT 111 in theaxiom-serverProxmox homelab (axiom-runner-1..4, registered org-scoped tostwl-labs, ephemeral one-job-per-container viamyoung34/github-runner). Net effect: tcg-vault CI no longer consumes GitHub Actions minutes for those four workflows. agent-context-drift.ymldeliberately stays onubuntu-latestper Decision D4 of the convoy — it's a weekly cron, costs ~2 min/month, and must run even when axiom is down for maintenance. Check 8 offorbidden-patternsenforces this as a strict allowlist (anything else reintroducingruns-on: ubuntu-latestfails CI).- Cache mounts live on the CT 111 host and are bind-mounted into every runner container, so they survive across the ephemeral-runner lifecycle and are shared across
axiom-runner-1..4. Paths (on CT 111):/opt/appdata/gha-runner/shared-cache/{npm,pnpm,yarn,pip,playwright,buildx}and the per-runner workdirs under/opt/appdata/gha-runner/runner-N/. Theactions/cache@v4keys above still apply on top — the bind mounts just keep the underlying tooling caches (~/.npm,~/.cache/ms-playwright) primed across jobs. - Migrate job uses CT 102 shared Postgres instead of an in-runner
services.postgrescontainer.HOMELAB_CI_POSTGRES_PASSWORDrepo secret (password only —PGHOST/PGUSER/PGPORTare hardcoded inci.yml). Each run creates a per-run database namedci_run_${run_id}_${run_attempt}and drops it in anif: always()cleanup step so failed migrations don't leak DBs. Thedeckhearth_cirole hasCREATEDBbut no superuser; a compromised runner can't reach other apps' databases on CT 102. - Cross-references: convoy decisions + risks in
.convoys/migrate-ci-to-self-hosted.md; homelab-side infra inaxiom-server/proxmox/ct111/README.md; revert path in § 7 below.
- 4 of 5 workflows run on the axiom homelab.
-
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. -
CI runs on the axiom homelab (CT 111). Four of the five workflows execute on
[self-hosted, axiom]runners managed in theaxiom-serverrepo (proxmox/ct111/). Day-to-day this is invisible — pushes still trigger jobs and Vercel still builds previews — but two operational notes matter:-
PAT rotation. The runners authenticate to GitHub via an org-scoped PAT stored on CT 111 at
/opt/appdata/gha-runner/.env(keyGH_PAT, scopesadmin:org,repo,workflow). Rotate every 90 days. After updating the value on CT 111, run./proxmox/scripts/sync.sh restart 111to re-register all 4 runners. If the PAT lapses silently, new jobs fail registration immediately; check./proxmox/scripts/sync.sh logs 111 gha-runner-1forHttp response code: NotFoundto confirm. -
1-line revert path (D5) — when axiom is offline mid-PR-storm. If CT 111 is down for maintenance, hardware swap, or any reason, and a hot fix needs CI to land, swap every
[self-hosted, axiom]back toubuntu-latest:sed -i '' 's/\[self-hosted, axiom\]/ubuntu-latest/g' .github/workflows/*.yml # macOS sed needs the empty -i '' argument; on Linux it's `sed -i 's/...//g' ...`.This re-bills GitHub Actions minutes for the duration of the outage. Commit the change directly to
main(or to the affected PR's branch), let CI run, and revert the sed result once axiom is back. Theforbidden-patternsCheck 8 will block the next normal PR until the revert lands — that's intentional: the gate exists exactly to surface this drift, not to silently re-bill minutes for weeks. Beszel alerts on CT 111 down (axiom-server CT 101) so you usually know before a PR notices.
-
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.