deckhearth/AGENTS.md
Randall Stillwell c2a43a0ce2 docs: post-convoy cleanup for fix-vercel-deployment-protection-in-ci
Reflects the merged fix-vercel-deployment-protection-in-ci convoy
(PR #17, squash commit 9a3e077) in repo documentation. Closes the
CI-infra side-effect of P0 #7. Three commits in the convoy: Brief 1
(bypass plumbing per spec), plus two scope expansions discovered
during CI validation (shell-injection hardening, and a Decision-A
shape correction to drop the cookie-bypass param).

.convoys/ship-readiness.md:
  - Queued convoys: mark fix-vercel-deployment-protection-in-ci as
    RESOLVED 2026-05-24 with 9a3e077. Document the 3-commit reality
    (365e9f0 Brief 1 bypass plumbing, b6f8688 shell-injection
    hardening of the gate Decide step, 043a6ee dropping
    &x-vercel-set-bypass-cookie=true), the empirical metrics
    (wait-action: 10-min timeout -> 194ms; workflow runtime:
    10+ min -> 59s), the documented expected red on Playwright
    smoke (npx playwright test fails because playwright.config.js
    doesn't exist yet -- adopt-playwright-smoke owns that), and the
    operator-rotation caveat (R6).

AGENTS.md:
  - Section 7 Deployment: correct the noun "header" -> "query param
    on wait-action's path:" since that's what actually landed per
    Decision A. Also document the without-cookie form (the cookie
    variant 401s through axios's missing cookie jar) and the
    operator re-seed runbook for token rotation.
  - Section 7 Deployment: fold in a one-liner about the GitHub
    Actions ${{ }}-in-shell-is-injection-vector pattern, with the
    env: + quoted-shell fix shape. Picked Section 7 over a new
    Gotcha #13 because the existing Gotchas list is dominated by
    app-level pitfalls (auth, SQL clients, ESLint), and CI YAML
    hardening is naturally co-located with deployment.

.convoys/fix-vercel-deployment-protection-in-ci.md:
  - frontmatter status: in-progress -> shipped (added shipped:
    2026-05-24)
  - new ## As-shipped section: 3-commit reality, Decision-A shape
    deviation (we shipped without &x-vercel-set-bypass-cookie=true),
    empirical timings (194ms wait, 59s total), remaining-red
    attribution to adopt-playwright-smoke, and the
    operator-rotation caveat.

No changes to: package.json, lib/**, pages/**, components/**,
scripts/**, .github/workflows/**, .cursor/rules/**, README.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 16:32:45 -05:00

16 KiB

AGENTS.md — AI collaboration (tcg-vault)

Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.

Branding note: the repo, README, and seed data say "TCG Vault" and admin@tcgvault.com, but the Layout component renders "Deck Hearth". Pick one before launch — see .convoys/ for tracking.

1. Project overview

A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.

  • Framework: Next.js 16 (Pages router) + React 18, JavaScript (not TypeScript — see Gotcha #9)
  • Data: Neon Postgres, accessed two different ways — @neondatabase/serverless (lib/database.js) AND raw @vercel/postgres (pages/api/**). Pick ONE; see Gotcha #1.
  • Auth: Custom JWT (jsonwebtoken + bcryptjs), token stored in localStorage, sent 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 (5 attempts / 15 min via @upstash/ratelimit). The seed admin row is created at admin@tcgvault.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: Vercel (vercel.json, .vercel/ present)

2. Architecture quick reference

Area Path Notes
Pages router views pages/*.js Public + auth views; uses components/Layout.js
API routes pages/api/**/*.js Express-style handler(req, res). 30+ handlers depend on lib/permission-middleware.js::getUserFromRequest
Shared UI components/*.js Layout, CardItem, CameraScanner, modal family
Auth + DB libs lib/*.js auth-context, admin-auth, use-auth (three parallel auth surfaces), database, permission-middleware
Migration scripts scripts/*.js 27+ one-off "add column" / "seed" scripts. No formal migration tool
Card-import jobs pages/api/cards/import-*.js, scripts/import-*.js Scryfall / Lorcana / Pokémon TCG APIs
Database schema scripts/setup-neon-db.js Bootstrap SQL DDL — the source of truth until a real migration tool lands
Schema map docs/SCHEMA_MAP.md Hand-curated; regenerate after schema changes

Code graph is indexed by user-code-review-graph MCP (122 files, 628 nodes, 5602 edges). Ask: "what calls getUserFromRequest?" before refactoring auth.

3. Key conventions

  • Auth (server): import { getUserFromRequest } from '../../lib/permission-middleware' → returns { userId, email, role } 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'. Avoid lib/auth-context.js and lib/admin-auth.js for new code — they are legacy parallel implementations.
  • 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 '@vercel/postgres'. Avoid the legacy lib/database.js db.query(string, params) API; its parameter interpolation uses sql.unsafe and is a SQL-injection vector.
  • Activity logging: logCollectionActivity(collectionId, userId, action, details) — call it from any handler that mutates a collection.
  • File names: kebab-case.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.

4. Common gotchas

  • #1 — Two SQL clients live in parallel. @neondatabase/serverless (used by lib/database.js) and @vercel/postgres (used by most pages/api/** handlers). New code: prefer @vercel/postgres tagged templates. Migration to a single client is tracked in .convoys/.
  • #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 must rotate manually via the app, or wait for the queued rotate-default-admin follow-up convoy. Entry kept (not renumbered) to preserve cross-references.
  • #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 new forbidden-endpoints job 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 are bare scripts. scripts/add-*.js and scripts/fix-*.js are run-once jobs with no idempotency tracking. Adopt node-pg-migrate, kysely, or drizzle-kit before more schema changes.
  • #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-limit env vars are KV_REST_API_URL / KV_REST_API_TOKEN, not UPSTASH_REDIS_REST_*. lib/rate-limit.js reads 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/redis REST client does not use them — do not wire to them. In prod, the rate-limit module fails closed if either of the two REST vars is missing (a single failed login is a better outcome than silently disabling brute-force protection). In dev / test, it warn-and-continues as a no-op so local work is unaffected when Upstash isn't wired up.

5. Running locally

  • Runtime: Node 20 (Vercel default).
  • Setup: npm install, copy .env.local template (POSTGRES_URL + JWT_SECRET + RESEND_API_KEY + BLOB_READ_WRITE_TOKEN + ADMIN_INITIAL_PASSWORD — the last is required for npm run setup-db and 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.js warn-and-no-ops in dev), then npm run setup-db once.
  • 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). Specs live under test/ mirroring source layout (test/lib/*.test.js, test/api/*.test.js).
  • Coverage today: 16 unit tests covering the post-fix-auth-bypass auth surface — lib/auth-secret.js (3), lib/permission-middleware.js::getUserFromRequest (8, incl. a negative regression against the old synthetic-admin shape — Gotcha #2), and pages/api/auth-utils.js (5). These tests lock in the contracts established by Briefs 1 and 2; do not weaken them when refactoring auth.
  • CI: 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.
  • E2E / smoke runner: @playwright/test is still pending — queued for the adopt-playwright-smoke convoy (see .convoys/ship-readiness.md § Proposed launch sequence step 10). Until it lands, preview-smoke.yml and visual-diff.yml are no-ops on the smoke side.
  • Manual QA: TESTING_GUIDE.md still applies for surfaces not yet covered by automated tests (UI flows, scanner camera path, import jobs).

7. Deployment

  • Vercel auto-deploys main and creates Preview deployments for every PR. vercel.json and .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_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 preview-smoke and visual-diff workflows pass the bypass as a query parameter on wait-for-vercel-preview@v1.3.2's path: input — 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). The header form (x-vercel-protection-bypass: <secret>) is reserved for the future playwright.config.js that adopt-playwright-smoke will ship (Playwright has its own request shape and extraHTTPHeaders is cleaner there). 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.
  • 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.

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.