- Export DEFAULT_LAYOUT from lib/frame-palette.js so the custom-frames route can import it at runtime (not just the hardcoded copy in tests). - Fix vitest mock isolation in test/api/custom-frames.test.js: beforeEach now uses mockReturnValue instead of mockResolvedValue to avoid resolving the default mock in each test; test cases provide specific mock chains with mockResolvedValueOnce. Fixes 4 tests that were bleeding state between cases due to leftover queued mock values. - Fix validateLayout test coordinates: art w+h=0.924 and 0.398 are both within the 0-1 fraction range so x+w=0.962<1 and y+h=0.982<1 pass. - Add dedicated validateLayout unit tests (accepts, rejects missing zone, rejects out-of-bounds). - Fix update test mock chain: PUT calls SELECT (found) then SELECT (clash) then UPDATE (RETURNING) — provide all three in order. - Fix DELETE test: owns via SELECT then executes DELETE (2 calls).
42 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 GitHub repo is nowstwl-labs/deckhearth(renamed fromstwl-labs/tcg-vault; local checkout folders namedtcg-vaultare fine). 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.
Infra note (2026-08): Deck Hearth is moving off Vercel + Neon onto the axiom homelab — Postgres/Redis/MinIO on CT 102, app on Dokploy CT 112, public URL
deckhearth.stillwell.cloud. Runtime DB access goes throughlib/sql.js(thepostgrespackage), not@vercel/postgres; rate limiting readsREDIS_URL. CI already gates against the homelab deployment; Neon/Vercel decommission is pending (migrate-neon-to-homelabconvoy phases 6–8). See § 5–§ 7 anddocs/DOKPLOY_DEPLOY.md/docs/HOMELAB_DATABASE.md.
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: Postgres 17 + pgvector on the axiom homelab (CT 102,
192.168.68.102:5432). Runtime DB access goes throughlib/sql.js— a tagged-templatesqlhelper over thepostgrespackage returning{ rows, rowCount }(the former@vercel/postgresshape, so call sites only changed their import). Migrations readPOSTGRES_URL_DIRECT. 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 vialib/rate-limit.js(see Gotcha #12). 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: Dokploy on CT 112 (
deckhearth.stillwell.cloud, Traefik on CT 100). Vercel-era config (vercel.json,.vercel/) is still in the tree pending decommission (migrate-neon-to-homelabphases 6–8) — seedocs/DOKPLOY_DEPLOY.md.
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 (6 named limiters — see Gotcha #12), sql.js (canonical Postgres client — tagged-template helper over the postgres package), object-storage.js (MinIO/S3 scan-capture uploads). The legacy lib/database.js was deleted by single-sql-client (PR #30, c403ea4). |
| 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 | migrations/ + scripts/setup-neon-db.js |
node-pg-migrate migrations are the source of truth post-migration-tool; setup-neon-db.js chains migrate up + admin seed |
| 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 '../lib/sql.js'(path relative to the caller). 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.lib/sql.jsreadsPOSTGRES_URL(falls back toDATABASE_URL) and returns the former@vercel/postgresresult shape{ rows, rowCount }. Forscripts/**helpers that legitimately need the Neon HTTP driver during the transition (e.g.reset-db.js, the rename-email migration), import{ neon } from '@neondatabase/serverless'directly and use tagged-template SQL (await sql\...``) — the safe shape, not a string-interpolating wrapper. - 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 rotate via the newscripts/rotate-admin-password.js(seerotate-default-adminresolution below). Entry kept (not renumbered) to preserve cross-references.Rotation script —
rotate-default-adminresolution (2026-06-13).scripts/rotate-admin-password.jscloses the operator caveat above with a one-shot, audit-trail-preserving rotation:POSTGRES_URL=<prod-url> \ ADMIN_NEW_PASSWORD=$(openssl rand -base64 24) \ node scripts/rotate-admin-password.jsFail-loud-exits BEFORE opening a DB connection if
POSTGRES_URL/ADMIN_NEW_PASSWORDare missing or the password is shorter than 12 chars. Validates the target row exists AND hasrole = 'admin'before touching it (refuses to rotate non-admin rows). Verifies the new bcrypt hash matches the supplied plaintext viabcrypt.comparepost-update. Never echoes the password. OptionalADMIN_EMAILoverride defaults toadmin@deckhearth.com; passadmin@tcgvault.comto target a pre-pick-a-name-rename env. Sibling test users (alice / bob inscripts/create-test-users.js) are intentionally NOT rotated — they're dev fixtures.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 limiting reads
REDIS_URL(homelab Redis, CT 102).lib/rate-limit.jsis backed byioredis+rate-limiter-flexiblewith six named limiter classes (auth,search,upload,generate,import, and the scanner-erascan— 15/min user-keyed), each with its owndeckhearth:*Redis key prefix. The Vercel-Upstash era vars (KV_REST_API_URL/KV_REST_API_TOKEN) are obsolete — do not wire to them. In production the module fails closed ifREDIS_URLis 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 Redis isn't reachable.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, distinct Redis prefix per class). A sixth (scan, from the scanner-era hardening) joined later — current full set: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-pokemoncheckScanRateLimit(req, userId)scan15 / 1 min user deckhearth:scan/api/scan/identify(one camera verify may escalate L0→L2; vision path is the expensive step)Prefixes 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 six 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 another 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+ (
"type": "module"— ESM everywhere). - Setup:
npm install, create.env.localwithPOSTGRES_URL(+POSTGRES_URL_DIRECTfor migrations) pointing at CT 102 or any Postgres 17, plusJWT_SECRET+ADMIN_INITIAL_PASSWORD(required fornpm run setup-db, which exits with code 1 if unset). OptionallyREDIS_URLto exercise the rate limiter locally — without it,lib/rate-limit.jswarn-and-no-ops in dev (production fails closed) — and theS3_*MinIO vars for scan-capture uploads. Thennpm run setup-dbonce. Full env contract in README § Installation anddocs/DOKPLOY_DEPLOY.md. - 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, and stubsResizeObserverfor jsdom). Specs live undertest/mirroring source layout (test/lib/*.test.js,test/api/*.test.js,test/components/*.test.js). Last green: 231/231 tests across 43 files (2026-08-23). -
Vitest coverage today: 231 unit tests spanning auth (
lib/auth-secret.js,lib/permission-middleware.js::getUserFromRequestincl. the negative regression against the old synthetic-admin shape — Gotcha #2),pages/api/auth-utils.js, Layout logged-out regressions (Gotcha #8), scanner libs/hooks/components (use-scanner-identification,use-camera-scanner,ScannerCamera, scanner page), card import + reconcile helpers, and catalog sync. The original fix-auth-bypass / fix-layout-default-user contract tests are still present — 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. CI defaultsBASE_URLto the homelab deployment (https://deckhearth.stillwell.cloud) viavars.SMOKE_BASE_URL; a legacy Vercel-preview target still works withBASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value>but is pending decommission (§ 7). 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: committed under
tests/visual/__screenshots__/. The initial Linux baseline (home.png) was seeded by PR #58 (83a358b, 2026-06-02). Baselines are committed to git — they are not gitignored — so aScreenshot difffailure is reviewable from PR comments + artifacts without bouncing through a regeneration step. Re-seeding (when the homepage changes intentionally) MUST happen in a Linux environment so the PNG matches what CI produces. Recommended paths:-
Playwright Docker image (works from any host):
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" -
CT 111 directly (preferred when iterating — same toolchain as the diff workflow, byte-equivalent output). Dispatched via the (queued)
seed-visual-baselinesworkflow once it lands; until then,pct exec 111 -- docker exec gha-runner-1 sh -c "..." works ad-hoc.
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. Never runnpm run test:visual:updateon a Mac unless you immediately throw the result away.Current baseline: refreshed against post-glass-redesign
mainvia PR #139 (54495fe, 2026-06-13), generated on CT 111 against thec100c5fproduction deployment. Diff is a hard merge gate post-harden-visual-diff-gatebrief 2 — see the next bullet. -
-
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.js(andtests/visual/**for baseline updates) viavisual-diff.yml. It is now a hard merge gate post-harden-visual-diff-gatebrief 2 (PR #140, 2026-06-13) —continue-on-error: truewas removed. A failed diff blocks merge.- Intentional UI change? Dispatch the seeding workflow first:
gh workflow run seed-visual-baselines.yml -f base_url=<preview-url> -f reason="...". It pushes abot/visual-baselines-<run_id>branch with a refreshedhome.png. The auto-PR-open step fails because stwl-labs has "Allow GitHub Actions to create and approve pull requests" disabled at the org level (Settings → Actions → General → Workflow permissions); the operator runsgh pr create --base main --head bot/visual-baselines-<run_id> ...manually. Merge the baseline PR, then re-run the UI-touching PR's visual diff. - Unintentional regression? Open the run's artifact bundle, inspect the diff PNG, fix the regression in source, push.
- Forbidden re-introduction:
ci.yml'sforbidden-patternsjob's 9th check fails any PR that re-addscontinue-on-error: truetovisual-diff.yml.
- Intentional UI change? Dispatch the seeding workflow first:
- 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
Status (2026-08): production is the Dokploy homelab deployment — app on CT 112, public URL
https://deckhearth.stillwell.cloudvia Traefik on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). Runtime DB access goes throughlib/sql.js(thepostgrespackage); rate limiting readsREDIS_URL. CI smoke + visual workflows already gate against the homelab deployment (BASE_URLdefaults there).vercel.jsonand.vercel/are removed from the tree. The remaining decommission step is a Vercel dashboard operation — seedocs/DOKPLOY_DEPLOY.md§ 6. Runbook:docs/DOKPLOY_DEPLOY.md.
-
Vercel (legacy, decommissioned code-side). The
vercel.json/.vercel/files are removed. The Dokploy deployment atdeckhearth.stillwell.cloudis the canonical production target. Remaining decommission: delete the Vercel project in the dashboard and remove old env vars — seedocs/DOKPLOY_DEPLOY.md§ 6. -
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 Dokploy buildsmainon CT 112 (Vercel preview builds continue only until phase 8 decommission) — 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.