deckhearth/.cursor/rules/api-routes.mdc
Randall Stillwell d640795959 docs: post-convoy cleanup for pick-a-name — first post-P0 P1 convoy
Reflects the merged pick-a-name convoy (PR #21, squash commit 9abbab6)
in repo documentation. **This is the first post-P0 cleanup** —
add-rate-limiting (squash 708ef45) 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 commit ac8c998
display + comment sweep, B2 commit 1c18d21 infrastructure + 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 — 9abbab6 merged
    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 (squash 9abbab6, 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>
2026-05-25 03:04:29 -05:00

172 lines
11 KiB
Text

---
description: Conventions for Next.js Pages-router API route handlers in tcg-vault
globs: pages/api/**/*.js
---
# API Route Conventions
Pages router handlers; `(req, res)` signature; Vercel serverless functions.
## Authentication & Authorization
Two paths are in use; both go through `lib/permission-middleware.js`.
```js
// 1. Generic auth — every protected route
import { getUserFromRequest } from '../../lib/permission-middleware';
export default async function handler(req, res) {
const user = await getUserFromRequest(req);
if (!user) return res.status(401).json({ error: 'Authentication required' });
// user = { userId, email, role }
// ...
}
```
```js
// 2. Collection-scoped auth — when the route operates on a specific collection
import { withCollectionPermission } from '../../lib/permission-middleware';
async function handler(req, res) {
// req.user and req.permission are populated by the wrapper
const { userId, role } = req.user;
}
export default withCollectionPermission('viewer')(handler);
// 'viewer' | 'editor' | 'owner' — checks owner OR is_public OR explicit collection_permissions row
```
`getUserFromRequest` returns `null` for any unauthenticated request (missing header, malformed token, wrong signature, expired token, unknown user id). The early `if (!user) return res.status(401)` pattern in the snippet above is the canonical guard for every authenticated route. (The pre-`fix-auth-bypass` synthetic-admin fallback for missing tokens has been removed — see `AGENTS.md` Gotcha #2 for the audit trail.)
## Request validation
No schema validator is installed (no zod / yup / valibot). Validate manually:
```js
const { name, game } = req.body || {};
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'name is required' });
}
if (!['mtg', 'pokemon', 'lorcana'].includes(game)) {
return res.status(400).json({ error: 'invalid game' });
}
```
When adding zod (planned in `.convoys/`), define schemas at the top of the file.
## Method gating
Reject unsupported methods explicitly — Next.js will otherwise call the handler for any method:
```js
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
```
## Error handling
Wrap the handler body in `try/catch`. Never throw unhandled — leaks stack traces in the Vercel response.
```js
export default async function handler(req, res) {
try {
// ...
} catch (err) {
console.error('[POST /api/collections]', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
```
## Database access
- **Prefer:** `import { sql } from '@vercel/postgres'` and tagged templates: `` await sql`SELECT … WHERE id = ${id}` ``.
- **Avoid:** `import { db } from '../../lib/database'`. Its `query(str, params)` API uses `sql.unsafe` after manual interpolation — SQL-injection vector. Slated for removal in a convoy.
- **Always select narrow columns** — don't `SELECT *` from `cards` (large `oracle_text`, `colors` JSONB).
## Response shape
- Success (GET / read): `res.status(200).json({ data: ... })` OR direct payload — codebase is inconsistent; match the surrounding route's existing shape.
- Created (POST): `res.status(201).json({ data: ... })`.
- Errors: `res.status(<4xx|5xx>).json({ error: string, details?: unknown })`.
## Activity logging
Any handler that mutates a collection must call `logCollectionActivity`:
```js
import { logCollectionActivity } from '../../lib/permission-middleware';
await logCollectionActivity(collectionId, userId, 'card_added', { cardId, quantity });
```
## Rate limiting
`lib/rate-limit.js` exposes five named limiters, one per route class. Each named export takes `req` (and `userId` for user-keyed classes) and returns `{ allowed, remaining, reset }`. The auth-only limiter shipped in `fix-auth-bypass` Brief 4 (commit `297afca`, login + register); the four remaining classes — `search`, `upload`, `generate`, `import` — and the seven currently-gated routes shipped in `add-rate-limiting` (squash commit `708ef45`, PR #20, 2026-05-24), the convoy that closed P0 #6 and brought the launch-readiness P0 set to 8/8 RESOLVED. The five Redis key prefixes were renamed `tcgvault:*` → `deckhearth:*` in `pick-a-name` (squash commit `9abbab6`, PR #21, 2026-05-24) — call shape, return shape, and gate-ordering rules below are byte-identical post-rename; only the on-Redis namespace changed (one-time per-window counter reset accepted).
| Class | Limit | Window | Key | Used by | Helper |
| --- | --- | --- | --- | --- | --- |
| `auth` | 5 | 15 min | IP | `/api/auth/login`, `/api/auth/register` | `checkAuthRateLimit(req)` |
| `search` | 60 | 1 min | IP | `/api/users/search`, `/api/cards/search` | `checkSearchRateLimit(req)` |
| `upload` | 10 | 1 hour | user | `/api/user/avatar` | `checkUploadRateLimit(req, userId)` |
| `generate` | 5 | 1 hour | user | `/api/user/avatar/generate` | `checkGenerateRateLimit(req, userId)` |
| `import` | 5 | 1 hour | user | `/api/cards/import-mtg`, `/api/cards/import-pokemon`, `/api/cards/import-lorcana` | `checkImportRateLimit(req, userId)` |
**Verbatim call shape** (identical across all five classes — only the helper name and the optional `userId` argument differ):
```js
import { checkSearchRateLimit } from '../../../lib/rate-limit.js';
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
// For user-keyed classes, auth check goes HERE first; see "Gate ordering" below.
const { allowed, reset } = await checkSearchRateLimit(req);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
try {
// ... handler body ...
} catch (err) {
// ...
}
}
```
**Gate ordering rules:**
1. **Method check first.** Reject the wrong verb with 405 before doing any limiter work.
2. **Auth check before any user-keyed limiter.** `extractUserIdentifier(userId)` THROWS when `userId` is null/undefined/empty (defensive). For `upload`, `generate`, and `import`, the handler MUST call `getUserFromRequest(req)` (or equivalent JWT verification) and confirm a non-null user BEFORE calling the limiter. Wrong order = anonymous user bypasses (the THROW surfaces immediately during dev; do not catch and silently fall back to IP).
3. **For IP-keyed limiters (`auth`, `search`), gate placement is flexible** — either at the top of the handler (after the method check) or after a separate auth check that the route happens to also have (e.g. `users/search` JWT-verifies before rate-limiting, both are correct). The limiter only needs `req` for IP extraction.
4. **Admin-role check, if applicable, goes between auth and rate-limit.** Used by all three `/api/cards/import-*` routes: `if (user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' })` sits between the `if (!user)` 401 and the import rate-limit call.
**Identifier extraction:**
- `extractIpIdentifier(req)` (module-private) — first hop in `x-forwarded-for` (Vercel's edge), falling back to `req.socket.remoteAddress`, falling back to the literal `'anonymous'`. Do NOT key off `req.body.email` (rotates) or `req.headers.authorization` (unauthenticated endpoints don't have one).
- `extractUserIdentifier(userId)` (module-private) — formats as `user:${userId}`. Throws on null/undefined/empty/NaN to surface gate-ordering bugs at dev time rather than silently falling back to IP and creating a per-IP-not-per-user limit.
**Env vars (unchanged from Brief 4):** `KV_REST_API_URL` + `KV_REST_API_TOKEN` (auto-provisioned by Vercel's Upstash Marketplace integration). In prod, missing either var is a **fail-closed throw** on the first call. In dev / test, the module warn-and-no-ops so local work isn't blocked. See `AGENTS.md` Gotcha #12 for the full env-var contract.
**429 response shape is uniform across all five classes.** Same error message (`'Too many attempts. Try again later.'`) and same `Retry-After` header calculation. Per-class variation would fingerprint the limits to an attacker.
**Fail-open on Upstash outage.** A network failure inside `ratelimit.limit(...)` returns `{ allowed: true, remaining: Infinity, reset: 0 }` with a single `console.error('[rate-limit]', err)`. Reasoning: a hard Upstash outage should not lock the entire user base out of every gated route. Brute-force / abuse protection lives behind defense-in-depth (Vercel firewall, future fail2ban-style lockout).
## Dev/test endpoints (removed)
The four endpoints `pages/api/simple.js`, `pages/api/test-auth.js`, `pages/api/test-db.js`, and `pages/api/setup-database.js` used to exist as unauthenticated dev / diagnostic routes. They were **deleted** by `fix-auth-bypass` Brief 3 (commit `fc0dd73`) and `.github/workflows/ci.yml`'s `forbidden-endpoints` job now fails the build if any of them are re-introduced, or if any new file matching `pages/api/test-*.js` is added. **Do not re-create these files.** If a future agent searches for `test-db` or `setup-database` and finds them missing, this section is the explanation — diagnostics belong outside the public API surface (a CLI script, an admin-gated route, or `npm run` task).
## CORS
No `pages/api/**` route ships CORS headers. The frontend and the API are same-origin on Vercel (same project, same domain), and cross-origin reads serve no legitimate purpose on this API surface — the wildcard `Access-Control-Allow-Origin: *` that 24 handlers used to carry was scaffolding cruft, not a deliberate cross-origin design. `fix-auth-bypass` Brief 4 (commit `297afca`) removed it from `pages/api/auth/login.js` + `pages/api/auth/register.js`; the `cors-tighten` convoy (squash commit `da50d78`, PR #19) swept the remaining 24 handlers and added a new blocking `forbidden-cors-headers` job to `.github/workflows/ci.yml` (modeled on `forbidden-endpoints`) that fails the build if any `Access-Control-Allow-(Origin|Methods|Headers)` reference reappears under `pages/api/`.
Conventions to follow:
- **Do not add `res.setHeader('Access-Control-Allow-*', ...)` to any new route.** The CI gate will fail the build with a file-and-line pointer.
- **Do not add `if (req.method === 'OPTIONS')` preflight handlers.** Same-origin requests don't preflight; cross-origin requests are blocked at the browser CORS layer (the desired end state). If an OPTIONS request ever arrives, the existing method gate (`if (req.method !== '<verb>') return res.status(405)`) returns 405 — strictly safer than the pre-sweep 200-to-everyone.
- **If a future cross-origin caller is legitimately needed** (third-party app, mobile client, public API key program — none exist today), design a proper CORS layer — probably as Next.js middleware reading an allowed-origin list from env — rather than scaffolding wildcards back into individual handlers. That's a separate convoy (`add-cors-layer` or similar); flag it as a new follow-up in `.convoys/ship-readiness.md` § Queued convoys at the time the need surfaces.