Reflects the merged pick-a-name convoy (PR #21, squash commit9abbab6) in repo documentation. **This is the first post-P0 cleanup** — add-rate-limiting (squash708ef45) closed the LAST P0 ship-blocker on 2026-05-24, and pick-a-name immediately follows as the first post-P0 P1 quality-bar convoy, closing P1 #12 (brand-consistency — the inconsistency `AGENTS.md` line 5 had flagged since project setup). Two file-disjoint briefs landed serially (B1 commitac8c998display + comment sweep, B2 commit1c18d21infrastructure + email migration); five canonical-string D-decisions ratified verbatim at gate-1 plus Risk 4 PRESERVE on the negative regression-lock literal. Architect commit 50ce9ab; no scope expansions and no implementer deviations from the verbatim spec beyond the parallel-vs-serial dispatch choice (parallelizable in theory; serial in practice for lockfile-regen + migration-script audit cleanliness). .convoys/pick-a-name.md: - frontmatter Status: in-progress -> shipped (added Shipped: 2026-05-24 line, matching the file's existing **Status:** / **Opened:** markdown-line shape rather than YAML frontmatter). - new ## As-shipped section replacing the placeholder "(To be appended post-merge by role-doc-writer.)" stub. Opens with the post-P0 transition language pointing at ship-readiness's "8 of 8 RESOLVED" baseline + this convoy's role as the first P1 quality-bar closure. Decisions section captures all five D-ratifications verbatim (D1 Deck Hearth two-word internal-cap; D2 deck-hearth kebab matching the existing CSS class + Node convention; D3 deckhearth single-token for IDs/Redis; D4 admin@deckhearth.com placeholder TLD pre-domain-acquisition, swept across admin/alice/bob uniformly; D5 full deckhearth Redis prefix not abbreviated dh, with the architect's self-documenting-prefix-vs-memory-savings rationale preserved) plus the Risk 4 PRESERVE on test/lib/permission-middleware.test.js line 87's negative regression-lock literal — including the project-pattern justification (Gotchas #2/#3/#4/#5/#8 in AGENTS.md all preserve historical-shape locks for the same audit-trail reason). As-shipped surface broken into B1/B2 split with file-disjoint inventory + the serial-in-practice dispatch note (the conductor's parallel-via-multitask path stayed open as fallback). As-shipped diff captures B1 (7+/7-) + B2 (133+/30-) + lockfile STOP-on-churn confirmation (only the two `name` lines diffed; npm ci clean against regen). Empirical CI metrics from post-merge run 26387909625 (Aggregate gate 10s, Lint 45s 128 baseline, forbidden-endpoints + forbidden-cors-headers 4s each, Playwright smoke 1m4s 3/3, Screenshot diff 1m5s continue-on-error swallow per Decision 4, vitest 21/21 in 35s with Risk 4 PRESERVE test green). Cross-validation finding: 4th convoy in a row where the same 3-test smoke spec defends the auth surface through a sweeping change (PR #15 Layout -> #19 CORS -> #20 rate-limit -> #21 brand-rename). Implementer-vs-architect calibration note on the B2 forecast paragraph vs § Full surface inventory — both correct, cross-reference between them could be tighter for the next architect. Operator-action-required-going-forward: run the new migration script before the next admin login attempt (idempotent + UNIQUE-collision-safe). What did NOT change audit trail (~25 lines of historical .convoys/ tcgvault references stay byte-identical per no-go-zones, plus seed-collections-*.js historical scripts with stale WHERE-clauses that now no-op harmlessly post-migration, plus repo-name references deferred to rename-repo-and-vercel-project, plus the docs/agent-context + add-api-route SKILL.md repo-name references that sweep with the same downstream convoy). .convoys/ship-readiness.md: - § Status summary: P1-quality-bar-work-in-progress line added below the existing 8/8 RESOLVED milestone footer, citing pick-a-name as the first such convoy. - § P0 launch sequence step 5 (pick-a-name): marked RESOLVED 2026-05-24 with the squash commit + brief shape + smoke metrics + operator-action callout inline, mirroring the format of step 4 (add-rate-limiting RESOLVED) introduced by the previous doc-writer pass. - § Queued convoys: four new entries surfaced by pick-a-name inserted as a logical group after purge-weak-creds-from-helpers (the natural neighbor — all brand-bound + credential-bound post-pick-a-name follow-ups): - rename-repo-and-vercel-project (P2 polish; GitHub repo + Vercel project rename; auto-redirects make low-urgency; lists the 8 architect-verified literal-repo references that sweep together; Risk 5 .agent-context-manifest.yml source-tag re-evaluation noted). - point-domain-at-deckhearth (P2 polish; blocked on domain acquisition; DNS pointer + Vercel domain claim + admin email TLD swap if non-`.com`; current placeholder-email rationale preserved — auth uses email as identity not as mail target). - regenerate-brand-assets (P2 polish; favicon, OG images, social cards; requires design pass; cross-references the original Role-design-system-auditor finding about unifying DH logo + AnimatedFireLogo). - convert-reset-db-to-esm (P2 hygiene; CJS-in-ESM bug at scripts/reset-db.js:142; fold-into-purge-weak-creds-from-helpers noted since both convoys touch the same file). - seed-visual-baselines-on-linux entry: extended with the AFTER-pick-a-name ordering note (now satisfied —9abbab6merged 2026-05-24); first Linux baseline will capture Deck Hearth strings not pre-rename TCG Vault strings. - purge-weak-creds-from-helpers entry: scope narrowed — the @tcgvault.com email-half is now satisfied by pick-a-name B2; remaining scope is the admin123/alice123/bob123 weak password literals only (which survived pick-a-name deliberately per Brief 2's scope boundary). Fold-into-convert-reset-db-to-esm suggested (overlapping file). AGENTS.md: - line 5 (top-of-file branding note): rewritten in full from the B1-installed transitional shape ("Deck Hearth is the canonical product brand (ratified 2026-05-24...)") to the post-merge documented shape with the squash commit + PR # citation + the Vercel-project parenthetical + the Risk 4 PRESERVE caveat citing test/lib/permission-middleware.test.js. **This is the showstopper change** — it's the line that's been flagged as inconsistent since project setup; the rewrite finally closes the brand-consistency loop end-to-end. - line 13 (§ 1 Project overview Auth bullet): stale `admin@tcgvault.com` reference updated to `admin@deckhearth.com` to match the post-B2 seed state. Per Brief 2's "Files explicitly out of scope" line: "AGENTS.md line 13 = doc-writer scope". - Gotcha #4 (Default admin credentials in the seed): extended with a new paragraph below the existing operator-caveat copy documenting the post-pick-a-name email rename + the migration script + the migration-FIRST-then-setup-db ordering rule + the UNIQUE-collision-safe failure mode. Existing drop-public-setup operator-caveat copy preserved verbatim above the new content. - Gotcha #12 (Rate-limit env vars): per-class limiter table extended with a Redis-prefix column showing the renamed `deckhearth:*` values (was implicit/missing); one-liner about the rename + accepted counter reset added immediately below the table. Original env-var contract paragraph (KV_REST_API_URL / KV_REST_API_TOKEN, fail-closed-in-prod / warn-and-noop-in-dev) preserved verbatim above the new table. The defensive THROW + gate-ordering-rule + sixth-class-shape paragraphs below stay byte-identical from the add-rate-limiting milestone doc-writer pass. .cursor/rules/api-routes.mdc: - § Rate limiting opening lineage line: extended to add the pick-a-name attribution (squash9abbab6, PR #21, 2026-05-24) and to note the rename is namespace-only — call shape, return shape, and gate-ordering rules below are byte-identical post-rename. The convoy-attribution lineage now reads: fix-auth-bypass Brief 4 (auth class) -> add-rate-limiting (the other four classes + 7 routes) -> pick-a-name (Redis prefix rename). No example code blocks in this rule had `tcgvault:` literals to swap (verified via re-read); the per-class table earlier in the rule doesn't enumerate prefix values either, so the lineage line is the only edit needed. Files explicitly NOT changed: package.json, package-lock.json, lib/rate-limit.js, lib/**, pages/**, components/**, scripts/**, test/**, tests/**, .github/workflows/**, README.md, TESTING_GUIDE.md, playwright.config.js, eslint.config.mjs, scripts/migrations/2026-05-24-rename-admin-email.js. Doc-writer pass touches docs only. The four queued downstream convoys (rename-repo-and-vercel-project, point-domain-at-deckhearth, regenerate-brand-assets, convert-reset-db-to-esm) own the remaining brand-bound + helper-bound source-code work; this commit only records them in the queued-convoys ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
146 lines
24 KiB
Markdown
146 lines
24 KiB
Markdown
# 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 repo
|
|
> and Vercel project are still named `tcg-vault` — that rename is
|
|
> tracked in the queued `rename-repo-and-vercel-project` convoy
|
|
> (auto-redirects make it low-urgency). 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.
|
|
|
|
## 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@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:** 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.
|
|
|
|
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 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.
|
|
|
|
**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, five `Ratelimit` instances, distinct Redis prefix per class). The five exports + their use cases:
|
|
|
|
| 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`, `/api/cards/import-lorcana` |
|
|
|
|
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 five 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 three `/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 a sixth 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 (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 dev` → http://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`, `test/components/*.test.js`). Last green: 21/21 tests pass.
|
|
- **Vitest coverage today:** 21 unit tests — `lib/auth-secret.js` (3), `lib/permission-middleware.js::getUserFromRequest` (8, incl. a negative regression against the old synthetic-admin shape — Gotcha #2), `pages/api/auth-utils.js` (5), and `components/Layout.js` (5 regression-lock assertions for the post-PR-#15 logged-out branch — Gotcha #8). These tests lock in the contracts established by `fix-auth-bypass` Briefs 1 + 2 and `fix-layout-default-user`; 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:
|
|
- `smoke` — `tests/smoke/**/*.spec.@(ts|js)`; invoked by `.github/workflows/preview-smoke.yml`. `npm run test:smoke` locally.
|
|
- `visual` — `tests/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` (or against a deployed preview, `BASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value> npm run test:smoke`). 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:** none committed yet. `tests/visual/__screenshots__/` is intentionally absent and intentionally NOT in `.gitignore` (baselines, when they exist, must be committed). First-run baseline generation MUST happen in a Linux environment so the PNG matches what CI produces. Recommended path is the Playwright Docker image:
|
|
|
|
```
|
|
docker run --rm -v "$PWD":/work -w /work \
|
|
mcr.microsoft.com/playwright:v1.60.0-noble \
|
|
sh -c "npm ci && BASE_URL=<preview-url> \
|
|
VERCEL_AUTOMATION_BYPASS_SECRET=<value> \
|
|
npm run test:visual:update"
|
|
```
|
|
|
|
Mac-generated baselines will NOT match Linux CI — `playwright.config.js`'s custom `snapshotPathTemplate` has no `{platform}` token, so a Mac update silently overwrites the canonical Linux baseline. Tracked as the queued `seed-visual-baselines-on-linux` convoy (see `.convoys/ship-readiness.md` § Queued convoys).
|
|
- **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). Last measured runtime: 59s end-to-end, 3/3 tests pass in 2.9s (PR #18 post-merge run).
|
|
- **Screenshot diff:** runs only on PRs touching `pages/**` / `components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js` via `visual-diff.yml`. **First `Screenshot diff` run after `adopt-playwright-smoke` will fail at the test step** because no baseline exists yet; `continue-on-error: true` swallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state of `adopt-playwright-smoke`, not a regression — it stays that way until `seed-visual-baselines-on-linux` lands.
|
|
- **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
|
|
|
|
- **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 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.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`, 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.
|
|
|
|
## 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`](docs/agent-context/README.md).
|