--- 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 `/api/auth/login` and `/api/auth/register` are wrapped with a 5-attempt / 15-minute sliding window via `lib/rate-limit.js`. New endpoints on the public auth surface (or anywhere brute-force / credential-stuffing matters) should follow the same shape: ```js import { checkAuthRateLimit } from '../../../lib/rate-limit.js'; export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } const { allowed, reset } = await checkAuthRateLimit(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) { // ... } } ``` Notes: - Gate sits between the method check and the body. It MUST be inside the `try`/`catch` if you want Upstash errors to bubble — but `checkAuthRateLimit` already swallows them and fails-open, so the placement above is fine. - Identifier is the first hop in `x-forwarded-for` (Vercel's edge); do NOT key off `req.body.email` (rotates) or `req.headers.authorization` (login is unauthenticated by design). - Env vars are `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 — set them in Vercel project settings before merging anything that imports `lib/rate-limit.js`. In dev, the module warn-and-no-ops so local work isn't blocked. - The current scope is just the two auth endpoints. Sweeping the rest of the API (`/api/users/search`, `/api/cards/import-*`, avatar upload) is the queued `add-rate-limiting` convoy — follow the same pattern there. ## 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).