deckhearth/AGENTS.md
Randall Stillwell a81c6dc21b fix(tests): wire DEFAULT_LAYOUT export, fix custom-frames mocks, and add validateLayout tests
- 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).
2026-09-01 09:40:04 -05:00

42 KiB
Raw Permalink Blame History

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-name convoy, squash commit 9abbab6, PR #21). The GitHub repo is now stwl-labs/deckhearth (renamed from stwl-labs/tcg-vault; local checkout folders named tcg-vault are fine). Admin email is admin@deckhearth.com; the prior admin@tcgvault.com literal is deliberately preserved in test/lib/permission-middleware.test.js as 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 through lib/sql.js (the postgres package), not @vercel/postgres; rate limiting reads REDIS_URL. CI already gates against the homelab deployment; Neon/Vercel decommission is pending (migrate-neon-to-homelab convoy phases 68). See § 5§ 7 and docs/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-filter composition recipes from docs/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-filter on card grid items. GPU budget — glass goes on grid containers and detail views, not per-card. See docs/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 through lib/sql.js — a tagged-template sql helper over the postgres package returning { rows, rowCount } (the former @vercel/postgres shape, so call sites only changed their import). Migrations read POSTGRES_URL_DIRECT. 11 scripts/** 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's neon() directly — out-of-scope per the no-go-zones rule and tracked as the queued purge-neondatabase-serverless-fully follow-up. Schema changes ship as node-pg-migrate migrations under migrations/ 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 as Authorization: Bearer …. No NextAuth. The secret + canonical 24h TTL come from lib/auth-secret.js (single source of truth; throws at module load if JWT_SECRET is unset). getUserFromRequest returns null for unauthenticated requests — no synthetic admin fallback — and login + register are rate-limited via lib/rate-limit.js (see Gotcha #12). The seed admin row is created at admin@deckhearth.com with a password supplied via the required ADMIN_INITIAL_PASSWORD env var (scripts/setup-neon-db.js exits 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 the drop-public-setup convoy still have the old admin123 hash 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-homelab phases 68) — see docs/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 } or null. null means "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 === null means logged out, loading === true means token verification in flight. There is no client-side admin hook — compute const isAdmin = user?.role === 'admin' from the same useAuth() call. The legacy lib/auth-context.js + lib/admin-auth.js were deleted by single-auth-provider (PR #31, 0668b0c); do not reintroduce a <AuthProvider> / <AdminProvider> wrapper in pages/_app.js. The login + signup flow uses direct fetch('/api/auth/{login,register}') from pages/login.js / pages/signup.js — there is no useAuth().login(...) / useAuth().register(...) method; do not add one.
  • Layout user prop: pages should pass user from useAuth() to <Layout>. Layout's default is null and renders a logged-out "Sign in" CTA when no user is supplied — both paths are valid (some surfaces like pages/invite/{accept,decline}.js legitimately 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 from lib/auth-secret.js under the hood.
  • Rate limiting: import { checkAuthRateLimit } from '../../lib/rate-limit.js' for any new auth-surface endpoint (/api/auth/login + /api/auth/register already wired). Returns { allowed, remaining, reset }; on !allowed return 429 with a Retry-After header. See .cursor/rules/api-routes.mdc § "Rate limiting" for the verbatim shape.
  • Permission gate for collection routes: wrap handlers with withCollectionPermission('viewer' | 'editor' | 'owner') from lib/permission-middleware.js.
  • DB access: Use tagged-template style — import { sql } from '../lib/sql.js' (path relative to the caller). The legacy lib/database.js (db.query(string, params) wrapper around @neondatabase/serverless, which interpolated params into a string and called sql.unsafe) was deleted by single-sql-client (PR #30, c403ea4); do NOT reintroduce that shape. lib/sql.js reads POSTGRES_URL (falls back to DATABASE_URL) and returns the former @vercel/postgres result shape { rows, rowCount }. For scripts/** 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.js for libs/scripts; PascalCase.js for React components.
  • Imports: No path aliases configured; use relative imports.
  • Slugs: lib/slug-utils.js::generateUniqueSlug for any user-facing identifier (collections, decks).
  • CSS theme tokens: Components read var(--bg-primary), var(--text-primary), var(--accent-ember), etc. — defined in styles/. Don't hardcode hex colors.
  • Schema changes (post-migration-tool): new column / constraint / table work ships as a node-pg-migrate migration under migrations/ at the repo root. Generate via npm run migrate create <name> -- -j js, edit the up() (and down() when rollback is safe — for any migration that touches data, prefer a hard-stub down() that throws), and apply locally with npm run migrate up. npm run setup-db now chains npm run migrate up then seeds the admin user. The legacy scripts/add-*.js / scripts/fix-*.js / scripts/seed-*.js jobs are append-only history (no-go-zones rule); do NOT add new ones. See .convoys/migration-tool.md for 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-client convoy (PR #30, squash commit c403ea4, 2026-05-26). lib/database.js is deleted; the 2 callers (pages/api/auth-utils.js source + test/api/auth-utils.test.js mock) migrated to @vercel/postgres tagged 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 the sql.unsafe injection vector — the userId callers passed a numeric SERIAL from a verified JWT — so this was foot-gun removal rather than a live security finding. @neondatabase/serverless is still in package.json as a runtime dep because 11 scripts/* helpers continue to use neon() directly (setup-neon-db.js, migrations/2026-05-24-rename-admin-email.js, reset-db.js, plus 8 historical add-* / fix-* / seed-* jobs). Those scripts use the safe tagged-template shape (await sql\...`), not the deleted wrapper's unsafe db.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's pgclient, not@neondatabase/serverless, so the only remaining direct neon()consumers aresetup-neon-db.js(admin seed),reset-db.js`, and the historical graveyard). Entry kept (not renumbered) to preserve cross-references.

  • #2 — getUserFromRequest synthetic-admin fallback. RESOLVED by fix-auth-bypass Brief 2 (commit 258e479). The helper now returns null for unauthenticated requests; pages/api/auth/verify.js returns 401 on the no-token branch. The 16 unit tests in test/lib/permission-middleware.test.js lock 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-bypass Brief 1 (commit 4a10dce). lib/auth-secret.js is now the single source of truth and throws at module load when JWT_SECRET is unset. Canonical TTL is JWT_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-setup Brief 1 (commit ff80753) + Brief 2 (commit b63b509). scripts/setup-neon-db.js no longer hardcodes admin123; it reads ADMIN_INITIAL_PASSWORD from 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 24 generation tip, and CI-secret alternative. Brief 2 converted the script from CJS to ESM so npm run setup-db actually runs on Node 22.x (the bump-next-js convoy'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 weak admin123 hash — operators rotate via the new scripts/rotate-admin-password.js (see rotate-default-admin resolution below). Entry kept (not renumbered) to preserve cross-references.

    Rotation script — rotate-default-admin resolution (2026-06-13). scripts/rotate-admin-password.js closes 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.js
    

    Fail-loud-exits BEFORE opening a DB connection if POSTGRES_URL / ADMIN_NEW_PASSWORD are missing or the password is shorter than 12 chars. Validates the target row exists AND has role = 'admin' before touching it (refuses to rotate non-admin rows). Verifies the new bcrypt hash matches the supplied plaintext via bcrypt.compare post-update. Never echoes the password. Optional ADMIN_EMAIL override defaults to admin@deckhearth.com; pass admin@tcgvault.com to target a pre-pick-a-name-rename env. Sibling test users (alice / bob in scripts/create-test-users.js) are intentionally NOT rotated — they're dev fixtures.

    Post-pick-a-name (2026-05-24, squash 9abbab6), the seeded admin email is admin@deckhearth.com (and alice/bob test users likewise renamed). If you are deploying past 9abbab6 and the prod Neon DB still has @tcgvault.com rows, you MUST run node scripts/migrations/2026-05-24-rename-admin-email.js BEFORE the next admin login attempt or it 401s. The migration is ESM, idempotent, UNIQUE-collision-safe (fails loud if setup-neon-db.js already ran post-rename — which would indicate an ordering error). Order: migration FIRST, then any subsequent npm run setup-db.

  • #5 — pages/api/setup-database.js public endpoint. RESOLVED by fix-auth-bypass Brief 3 (commit fc0dd73). The file is deleted along with the other three dev endpoints (/api/simple, /api/test-auth, /api/test-db), and .github/workflows/ci.yml's forbidden-patterns job (formerly the standalone forbidden-endpoints job; consolidated by slash-ci-minutes convoy on 2026-06-04) fails the build if any of them are re-introduced (or if a new pages/api/test-*.js file appears). Entry kept (not renumbered) to preserve cross-references.

  • #6 — Migrations were bare scripts. RESOLVED by migration-tool convoy (2026-05-26). node-pg-migrate@^8 is the chosen tool (lightweight, raw-SQL-friendly, zero TS surface — matches the repo's JavaScript-only @vercel/postgres style). New migrations live under migrations/ at the repo root and use the default pgmigrations tracking table. The initial backfill migrations/1779853647564_initial-schema.js reproduces scripts/setup-neon-db.js's 7-table DDL verbatim using CREATE TABLE IF NOT EXISTS, so it's idempotent against fresh AND pre-existing envs — first-time npm run migrate up on an env that already ran setup-neon-db.js pre-convoy is a no-op DDL-wise (only records the pgmigrations row). The legacy 27 scripts/add-*.js / scripts/fix-*.js / scripts/seed-*.js jobs are append-only history per the no-go-zones rule — do NOT add new ones. New column / constraint work ships as a node-pg-migrate migration. See § 3 Conventions § "Schema changes" above + .convoys/migration-tool.md. Entry kept (not renumbered) to preserve cross-references.

  • #7 — Dual is_public semantics. Collections and decks both have is_public columns; check which controls discovery vs. anonymous read in the relevant route.

  • #8 — Layout has hardcoded default user. RESOLVED by fix-layout-default-user convoy (PR #15, squash commit ca302a8). components/Layout.js's default prop is now null; UserProfileDropdown renders a <Link href="/login">Sign in</Link> CTA when user === null. Brief 2 also swept the 7 pages that needed page-level fixes (scanner / decks / deck-builder / deck/[id] now pass user={user} to Layout; profile / settings replaced leaky useState({email:'me@…'}) with useState(null) + null-guards on every sync user.* read; card/[id] swapped a hardcoded const user = {...} for useAuth() from lib/use-auth.js). test/components/Layout.test.js adds 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.md and .convoys/ship-readiness.md P0 #7. Entry kept (not renumbered) to preserve cross-references.

  • #9 — typescript is a devDep, but the source is still JavaScript-only. package.json lists typescript@^5.9.3 purely so eslint-config-next@16's bundled typescript-eslint chain can satisfy its hard require('typescript') at module load (the peerDependenciesMeta.typescript.optional: true flag in eslint-config-next only suppresses npm's install-time warning, not the runtime require). There is no tsconfig.json, no .ts/.tsx files, and no // @ts-check directives. Do not rename .js files to .ts or add a tsconfig.json without 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.eslint is ^9.39.4 even though latest is 10.4.0. We tried v10 and npm run lint crashed with TypeError: scopeManager.addGlobals is not a function because eslint-config-next@16's bundled typescript-eslint@8.x predates ESLint v10's redesigned global-ingestion path. Reverted to v9 under Decision D. Do NOT bump ESLint independently — wait for the queued bump-eslint-10 follow-up convoy, which is upstream-blocked until typescript-eslint ships a v10-tested release that eslint-config-next bundles. See .convoys/bump-next-js.md § Decisions D + "Follow-up convoys queued".

  • #11 — Turbopack is now the default bundler. next dev and next build use Turbopack by default in Next.js 16. The fallback per command is --webpack (e.g. next build --webpack). We have no custom webpack: block in next.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.js is backed by ioredis + rate-limiter-flexible with six named limiter classes (auth, search, upload, generate, import, and the scanner-era scan — 15/min user-keyed), each with its own deckhearth:* 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 if REDIS_URL 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 Redis isn't reachable.

    Milestone — add-rate-limiting convoy (squash 708ef45, 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 a Map<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) auth 5 / 15 min IP deckhearth:auth /api/auth/login, /api/auth/register (Brief 4 contract; byte-identical return shape preserved)
    checkSearchRateLimit(req) search 60 / 1 min IP deckhearth:search /api/users/search, /api/cards/search
    checkUploadRateLimit(req, userId) upload 10 / 1 hour user deckhearth:upload /api/user/avatar
    checkGenerateRateLimit(req, userId) generate 5 / 1 hour user deckhearth:generate /api/user/avatar/generate
    checkImportRateLimit(req, userId) import 5 / 1 hour user (admin-only) deckhearth:import /api/cards/import-mtg, /api/cards/import-pokemon
    checkScanRateLimit(req, userId) scan 15 / 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:* in pick-a-name (squash 9abbab6, 2026-05-24); accepted one-time per-15-min / per-1-hour counter reset; existing Upstash state at tcgvault:* keys is now stale and will TTL out naturally.

    All six return the same { allowed, remaining, reset } shape; on !allowed, set Retry-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 when userId is null / 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). Numeric 0 is 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 also auth → 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_CONFIG addition + one new exported function (no init() restructuring needed). Tuning an existing class is a one-line LIMITER_CONFIG edit. 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.local with POSTGRES_URL (+ POSTGRES_URL_DIRECT for migrations) pointing at CT 102 or any Postgres 17, plus JWT_SECRET + ADMIN_INITIAL_PASSWORD (required for npm run setup-db, which exits with code 1 if unset). Optionally REDIS_URL to exercise the rate limiter locally — without it, lib/rate-limit.js warn-and-no-ops in dev (production fails closed) — and the S3_* MinIO vars for scan-capture uploads. Then npm run setup-db once. Full env contract in README § Installation and docs/DOKPLOY_DEPLOY.md.
  • Dev server: npm run devhttp://localhost:3000.

6. Testing

  • Unit-test runner: vitest@^3.2.4 (installed via fix-auth-bypass Brief 5, commit 1629afb). npm test for watch mode; npm run test:run for the CI / single-shot mode. Config in vitest.config.js, setup in test/setup.js (sets JWT_SECRET + NODE_ENV=test before any module loads, and stubs ResizeObserver for jsdom). Specs live under test/ 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::getUserFromRequest incl. 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 via adopt-playwright-smoke, PR #18 squash 7b6f751). Config in playwright.config.js (root, ESM) declares two projects:

    • smoketests/smoke/**/*.spec.@(ts|js); invoked by .github/workflows/preview-smoke.yml. npm run test:smoke locally.
    • visualtests/visual/**/*.spec.@(ts|js); invoked by .github/workflows/visual-diff.yml. npm run test:visual locally; npm run test:visual:update to (re-)seed baselines.

    Local-run convention: boot next dev in one terminal, then in another run BASE_URL=http://localhost:3000 npm run test:smoke. CI defaults BASE_URL to the homelab deployment (https://deckhearth.stillwell.cloud) via vars.SMOKE_BASE_URL; a legacy Vercel-preview target still works with BASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value> but is pending decommission (§ 7). No next dev auto-boot in the test scripts (Decision 6 of adopt-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 a Screenshot diff failure 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:

    1. 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"
      
    2. CT 111 directly (preferred when iterating — same toolchain as the diff workflow, byte-equivalent output). Dispatched via the (queued) seed-visual-baselines workflow 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 custom snapshotPathTemplate has no {platform} token, so a Mac update silently overwrites the canonical Linux baseline. Never run npm run test:visual:update on a Mac unless you immediately throw the result away.

    Current baseline: refreshed against post-glass-redesign main via PR #139 (54495fe, 2026-06-13), generated on CT 111 against the c100c5f production deployment. Diff is a hard merge gate post-harden-visual-diff-gate brief 2 — see the next bullet.

  • CI behavior:

    • Vitest: the test: job in .github/workflows/ci.yml runs npm run test:run on every PR and push to main and is blocking (no || true, no continue-on-error). A red test job blocks merge.
    • Playwright smoke: runs on every PR via preview-smoke.yml. Gate skip via pipeline: skip smoke in the PR body (handled in the gate: job's Decide step via env-var routing — see § 7's shell-injection note). Pre-migration runtime: 59s end-to-end on ubuntu-latest (PR #18 post-merge run). Post-migration on the axiom pool: cold-cache first run ~6 min (Chromium download); warm cache thereafter ~12 min.
    • Screenshot diff: runs only on PRs touching pages/** / components/** / styles/** / tailwind.config.js / postcss.config.js (and tests/visual/** for baseline updates) via visual-diff.yml. It is now a hard merge gate post-harden-visual-diff-gate brief 2 (PR #140, 2026-06-13) — continue-on-error: true was 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 a bot/visual-baselines-<run_id> branch with a refreshed home.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 runs gh 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's forbidden-patterns job's 9th check fails any PR that re-adds continue-on-error: true to visual-diff.yml.
  • 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-ignore for .convoys/**, **/*.md, docs/**, AGENTS.md, .cursor/**, and README.md. A pure-docs PR triggers zero GitHub Actions jobs (Vercel still builds — it's not on the Actions billing). visual-diff.yml was already cost-conscious via a positive paths: 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 consolidated forbidden-patterns job 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 job forbidden-stale-strings blocks ...) are now informational, not check-name lookups. pr-health-rollup.yml was unaffected because it only looks up Lint and Schema map up to date by name.
    • node_modules cached between runs in lint / test / migrate / preview-smoke / visual-diff. Keyed on package-lock.json hash so any dep change invalidates correctly. Cuts npm ci from ~30-45s to ~3-5s on cache hit. actions/setup-node@v4's built-in cache: npm is layered above this (caches ~/.npm) — both stay because the setup-node cache helps on cache-miss days too.
    • Playwright browsers cached in preview-smoke.yml + visual-diff.yml. Keyed on the resolved @playwright/test version from package-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.
  • 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.yml use runs-on: [self-hosted, axiom] and execute on CT 111 in the axiom-server Proxmox homelab (axiom-runner-1..4, registered org-scoped to stwl-labs, ephemeral one-job-per-container via myoung34/github-runner). Net effect: tcg-vault CI no longer consumes GitHub Actions minutes for those four workflows.
    • agent-context-drift.yml deliberately stays on ubuntu-latest per 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 of forbidden-patterns enforces this as a strict allowlist (anything else reintroducing runs-on: ubuntu-latest fails 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/. The actions/cache@v4 keys 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.postgres container. HOMELAB_CI_POSTGRES_PASSWORD repo secret (password only — PGHOST/PGUSER/PGPORT are hardcoded in ci.yml). Each run creates a per-run database named ci_run_${run_id}_${run_attempt} and drops it in an if: always() cleanup step so failed migrations don't leak DBs. The deckhearth_ci role has CREATEDB but 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 in axiom-server/proxmox/ct111/README.md; revert path in § 7 below.
  • Manual QA: TESTING_GUIDE.md still 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.md will be renamed to docs/MANUAL_QA.md and 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.cloud via Traefik on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). Runtime DB access goes through lib/sql.js (the postgres package); rate limiting reads REDIS_URL. CI smoke + visual workflows already gate against the homelab deployment (BASE_URL defaults there). vercel.json and .vercel/ are removed from the tree. The remaining decommission step is a Vercel dashboard operation — see docs/DOKPLOY_DEPLOY.md § 6. Runbook: docs/DOKPLOY_DEPLOY.md.

  • Vercel (legacy, decommissioned code-side). The vercel.json / .vercel/ files are removed. The Dokploy deployment at deckhearth.stillwell.cloud is the canonical production target. Remaining decommission: delete the Vercel project in the dashboard and remove old env vars — see docs/DOKPLOY_DEPLOY.md § 6.

  • Preview protection bypass for automation. The project has a Protection Bypass for Automation token exposed locally as VERCEL_AUTOMATION_BYPASS_SECRET in .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:

    1. Query parameter on wait-for-vercel-preview@v1.3.2's path: input in both preview-smoke.yml and visual-diff.ymlpath: '/?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, squash 9a3e077).
    2. HTTP header in playwright.config.js's use.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-level request fixture's APIRequestContext as well, so both page.goto(...) calls and request.get('/api/health') calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (adopt-playwright-smoke, squash 7b6f751) 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, the gh secret set rotation command, and points at this section); otherwise console.warn once and continue with extraHTTPHeaders undefined. Same fail-closed / warn-and-no-op shape as lib/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.md and .convoys/adopt-playwright-smoke.md.

  • Shell-injection hardening in workflow YAML. Never inline ${{ github.event.* }} directly into a run: block — route the value through the step's env: 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; commit b6f8688 swept both preview-smoke.yml and visual-diff.yml to the env: + 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 the axiom-server repo (proxmox/ct111/). Day-to-day this is invisible — pushes still trigger jobs, and Dokploy builds main on CT 112 (Vercel preview builds continue only until phase 8 decommission) — but two operational notes matter:

    1. PAT rotation. The runners authenticate to GitHub via an org-scoped PAT stored on CT 111 at /opt/appdata/gha-runner/.env (key GH_PAT, scopes admin:org, repo, workflow). Rotate every 90 days. After updating the value on CT 111, run ./proxmox/scripts/sync.sh restart 111 to re-register all 4 runners. If the PAT lapses silently, new jobs fail registration immediately; check ./proxmox/scripts/sync.sh logs 111 gha-runner-1 for Http response code: NotFound to confirm.

    2. 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 to ubuntu-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. The forbidden-patterns Check 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.