# Pack: HTTP wire / API

Load when: the touch list exhibits Express routes/middleware (`app.use(`, `router.get(`), the response envelope (`res.json({ status`), zod request validation, axios clients/interceptors, CORS config, webhooks received, or rate limiting.

## express5-route-string-syntax — HIGH · rendezvous-string
**Contract:** Express route strings are compiled by path-to-regexp v8 in Express 5; route authors and every URL caller meet on a path-string dialect that changed from Express 4.
**Detect:** `app\.(get|post|put|patch|delete|all|use)\(['"]`, `['"]\*['"]`, `:[a-zA-Z]+\?`, `:\w+\(`, `/\*splat`, `\{/:`, `req\.params\[0\]`, `req\.params\.splat`
**Ships green, breaks:** Old syntax `*`, `:id?`, `:id(\d+)` throws `TypeError: Missing parameter name` at boot — but the silent ones: `/*splat` no longer matches the mount root (`/files/*splat` misses `GET /files/`; only `/{*splat}` matches root); wildcard params are now ARRAYS of segments (`req.params.splat === ['a','b']`, not the string `'a/b'`; `req.params[0]` is gone → undefined); unmatched optional params are OMITTED from `req.params` instead of present-as-undefined; regex sub-patterns are unsupported, so numeric-only constraints once enforced by `(\d+)` silently stop filtering if approximated wrong.
**Safe change:** Use `/{*splat}` when root must match; treat splat as array (`req.params.splat.join('/')` where a string was expected); `:p?` → `{/:p}`, `:file.:ext?` → `/:file{.:ext}`; move regex constraints into handler/zod validation; run the Express v5 migration codemod and re-test every wildcard/optional route with real URLs.

## express5-query-parser-flip — HIGH · serialized-shape
**Contract:** Clients serialize query strings (qs brackets, axios `params`) and the server parses them per `app.set('query parser', ...)` — Express 5 flipped the default from `'extended'` to `'simple'`.
**Detect:** `app.set('query parser'`, `req\.query`, `\[\w+\]=` in client URL builders, `qs.stringify`, `paramsSerializer`, `express.urlencoded()`
**Ships green, breaks:** `?filter[status]=open` now parses to the flat key `"filter[status]"` instead of `{filter:{status:'open'}}` — loose schemas pass it through and filters silently no-op (endpoint returns unfiltered data); strict schemas start 400ing only for clients that send brackets. `req.query` is now a getter: sanitizer middleware doing `req.query = cleaned` silently no-ops (sloppy mode) or throws TypeError (strict). `express.urlencoded()` `extended` also now defaults to `false`.
**Safe change:** Pin `app.set('query parser', 'extended')` on migration (or convert clients to flat params); mutate `req.query` properties in place, never reassign; validate `req.query` with a strict zod schema so shape drift fails loudly.

## express5-removed-response-apis — HIGH · rendezvous-string
**Contract:** Handler code meets the Express request/response API by literal method name at runtime — nothing static links plain-JS call sites to Express 5's removed/changed methods.
**Detect:** `res.sendfile(`, `app.del(`, `res.redirect('back')`, `res.location('back')`, `req.param(`, `res\.(send|json|jsonp)\([^)]+,\s*\d{3}\)`, `res\.send\(\d{3}\)`, `clearCookie\(.+maxAge|expires`
**Ships green, breaks:** `res.redirect('back')` no longer resolves the Referrer — it redirects to the literal relative URL `back` (no error; users land on a 404). `res.clearCookie(name, {maxAge, expires})` silently ignores those options. `res.sendfile`, `app.del`, `req.param()`, `res.send(body, status)`, `res.send(404)` are removed → per-request TypeError → 500s only when the route is exercised. `res.status()` now throws for non-integers outside 100–999. `req.body` is `undefined` (not `{}`) when no parser ran, so `req.body.x` throws where v4 returned undefined.
**Safe change:** Replace `'back'` with `req.get('Referrer') || '/'`; `res.sendStatus(n)` / `res.status(n).json(obj)`; guard trust edges with `req.body ?? {}`; grep the whole detect list before touching any Express-4-era file.

## express-error-handler-arity — HIGH · lifecycle-protocol
**Contract:** Express identifies an error handler SOLELY by `fn.length === 4`; Express 5 additionally forwards every rejected async handler/middleware promise to that chain as `next(err)`.
**Detect:** `app.use((err,`, `(err, req, res, next)`, `express-async-errors`, `.catch(next)`, `next(err)`
**Ships green, breaks:** Deleting the "unused" fourth `next` param (a common lint autofix) silently turns the centralized error handler into ordinary middleware — it now runs on EVERY request and never on errors; errors fall through to Express's default handler, which emits HTML instead of the JSON envelope, and envelope-unwrapping clients break. With Express 5's native rejection forwarding, legacy `catch (e) { next(e) }` plus a rethrow (or a lingering `express-async-errors` shim) double-invokes the error chain → `Cannot set headers after they are sent`. Throws inside `setTimeout`/event callbacks are NOT forwarded and still crash the process.
**Safe change:** Always declare 4 params on error middleware (void the unused one); one terminal error handler emitting the envelope; in async handlers throw OR `next(e)`, never both; keep non-request-path callbacks in their own try/catch.

## api-response-envelope — HIGH · serialized-shape
**Contract:** Every response body is `{"status": 1, "message": "...", "data": {...}}` (errors: `{"status": 0, "message": "...", "data": null}`); axios interceptors unwrap `response.data.data` and branch on NUMERIC `status`.
**Detect:** `status: 1`, `status: 0`, `"status": 1`, `.data.data`, `interceptors.response.use`, `res.json({ status`, `data: null`
**Ships green, breaks:** Server emitting `status: true` breaks client `=== 1` / `=== 0` strict checks — responses classify as neither success nor error and fall through silently. A new endpoint returning a bare payload makes the unwrapper's `.data.data` yield `undefined` — spreads/destructures render empty state, no error thrown. If error envelopes move to non-2xx HTTP codes, axios's default `validateStatus` routes them into the REJECTED interceptor arm where the success unwrapper never runs — code must read `err.response.data.message`, not `res.data.message`. `data: null` on errors breaks optimistic `data.items.map`.
**Safe change:** Emit the envelope only via one `sendSuccess`/`sendError` helper (no inline literals); unwrap in one axios interceptor handling BOTH fulfilled and rejected arms; zod-parse the envelope at the client edge (`z.union([z.literal(0), z.literal(1)])` for `status`) so drift throws instead of undefined-ing.

## http-status-code-convention — HIGH · rendezvous-string
**Contract:** Server code choices and client interceptor branching meet on exact numeric codes: 200, 201 created, 400, 401 not-authenticated, 403 not-authorized, 404, 500 (plus 429/503 for backoff) — literals on both sides, no static link.
**Detect:** `res.status(4`, `sendStatus(`, `err.response?.status`, `=== 401`, `=== 403`, `Retry-After`, `status === 429`
**Ships green, breaks:** Returning 403 (or 200 + envelope `status: 0`) for an EXPIRED token means the client's 401→refresh-token interceptor never fires — sessions die silently or error-toast forever. Returning 401 for a pure permissions failure triggers refresh→retry loops that hammer the auth service and can force logouts. Backoff interceptors keyed to 429/503 don't engage if throttling ships as 400/500. 201-vs-200 breaks clients that branch on `status === 201` to read a created id.
**Safe change:** 401 strictly = not authenticated (bad/expired credential), 403 strictly = authenticated but not allowed; auth failures always ride HTTP codes, never 200+envelope-0; grep client interceptors for the exact literal before changing any handler's code; include `Retry-After` with 429.

## middleware-mount-order — HIGH · lifecycle-protocol
**Contract:** The `app.use` sequence is the contract: security/CORS → body parsers → auth/rate-limit middleware → routes → 404 → 4-arg error handler LAST.
**Detect:** ordering in `server.js`/`app.js`/`index.js`, `app.use(express.json`, `app.use(router`, `app.use(errorHandler`, `express.json({ limit`
**Ships green, breaks:** A router mounted above `express.json()` sees `req.body === undefined` in Express 5 → every POST 500s, or `req.body?.flag` goes falsy and the default branch runs for all users with no error. Routes registered AFTER the error handler still serve, but their errors bypass it → default HTML error responses that envelope clients can't parse. Auth or rate-limit mounted after routes is fail-open — unit tests that call handlers directly stay green. `express.json` only parses `Content-Type: application/json` — `navigator.sendBeacon` (text/plain) and pre-stringified axios posts arrive with `req.body` undefined.
**Safe change:** Keep one composition file owning the order above; never register routes in two places; when adding a parser-dependent route, confirm it's below the parser; smoke-test one POST through real HTTP per deploy, not just handler units.

## webhook-hmac-raw-body — CRITICAL · trust-invariant
**Contract:** The provider signs the exact raw request bytes; the server must verify the HMAC over those SAME bytes before trusting the event (often payment/provisioning events).
**Detect:** `express.raw(`, `rawBody`, `createHmac`, `timingSafeEqual`, `verify: (req, res, buf`, headers `stripe-signature`/`x-hub-signature-256`/`x-signature`
**Ships green, breaks:** Computing the HMAC over `JSON.stringify(req.body)` after `express.json()` produces different bytes (whitespace, unicode escaping, number formatting) → every signature mismatches → all deliveries rejected; the provider retries, then disables the endpoint — events silently lost. The common "fix" (skipping verification) fails OPEN to forged payment events. If `app.use(express.json())` runs globally, a route-level `express.raw()` gets an already-consumed stream → empty buffer → same permanent mismatch. `crypto.timingSafeEqual` THROWS RangeError on unequal lengths (length-check first), and `sig === expected` is a timing oracle. Providers redeliver on timeout/non-2xx → duplicate side effects.
**Safe change:** Mount `express.raw({ type: 'application/json' })` for the webhook path (or global `express.json({ verify: (req, res, buf) => { req.rawBody = buf } })`); length-check then `timingSafeEqual` on hex-digest Buffers; respond 2xx fast and process async; dedupe on the provider's delivery-id header persisted atomically with the side effect.

## zod4-silent-semantics — HIGH · serialized-shape
**Contract:** Request-validation schemas written with zod 3 idioms meet the zod 4 runtime, which silently ignores or re-interprets several idioms instead of erroring.
**Detect:** `required_error`, `invalid_type_error`, `errorMap`, `z.record(` with one arg, `.default(`, `.errors`, `.flatten()`, `.format()`, `.strict()`, `.passthrough()`
**Ships green, breaks:** `required_error`/`invalid_type_error` params were DROPPED — plain-JS call sites are silently ignored, custom messages vanish and clients string-matching them break (`errorMap` is renamed to the unified `error` param). Single-arg `z.record(z.string())` constructs fine and parses EMPTY objects fine, then throws on the first non-empty input; `z.record(z.enum([...]), v)` is now EXHAUSTIVE (missing enum keys fail — v3 was partial; use `z.partialRecord`). `.default(v)` now short-circuits on undefined and returns `v` as-is WITHOUT running transforms/coercions — use `.prefault()` for v3 behavior. `ZodError.errors` alias is removed → `err.errors.map` TypeErrors inside catch blocks (use `.issues`); `.format()`/`.flatten()` are deprecated for `z.treeifyError()`/`z.flattenError()`.
**Safe change:** On any zod-touching edit, grep for dropped params and one-arg `z.record`; audit enum-keyed records for intended partiality; re-test every `.default()` whose value relied on transforms (switch to `.prefault()`); replace `.errors` with `.issues` in all error handlers.

## zod-query-param-coercion — HIGH · serialized-shape
**Contract:** Query and path params arrive as strings (or string arrays); the schema's coercion layer is where wire strings become the types handlers assume.
**Detect:** `safeParse(req.query`, `parse(req.query`, `z.coerce.`, `z.coerce.boolean`, `req.query.` feeding numeric/boolean logic
**Ships green, breaks:** `z.coerce.boolean()` is `Boolean(x)` — `?active=false` and `?active=0` coerce to TRUE (any non-empty string is truthy). `z.coerce.number()` turns `?page=` (empty string) into `0`. Duplicate keys `?id=1&id=2` arrive as `string[]` and fail `z.string()` only for requests that actually send duplicates. Uncoerced `z.number()`/`z.boolean()` on query rejects every real request while handler unit tests (fed real numbers/booleans) stay green. Combined with Express 5's `'simple'` parser, nested-object query schemas never match at all.
**Safe change:** Use `z.stringbool()` (zod 4) or an explicit `z.enum(['true','false']).transform(...)` for flags — never `z.coerce.boolean()` on query strings; wrap repeatable params in `z.union([T, z.array(T)])`; design query schemas so their INPUT type is string, and test through real URLs.

## axios-error-and-timeout-contract — HIGH · rendezvous-string
**Contract:** Client catch-blocks and retry logic branch on axios 1.x's error taxonomy — `err.response` vs `err.request` vs config error, and literal `err.code` strings — plus its `validateStatus`/`timeout` defaults.
**Detect:** `err.response`, `err.request`, `err.code ===`, `'ECONNABORTED'`, `'ETIMEDOUT'`, `validateStatus`, `timeout:`, `transitional`, `isAxiosError`
**Ships green, breaks:** Default `timeout: 0` — axios NEVER times out; a hung upstream pins the request forever and retry logic never fires. When a timeout is set, it rejects with `code: 'ECONNABORTED'` (not ETIMEDOUT) unless `transitional: { clarifyTimeoutError: true }` flips it to `'ETIMEDOUT'` — retry code matching the other literal silently never retries. The timeout is socket-inactivity based, NOT a whole-request deadline: a slowly trickling response can run far past it. Default `validateStatus` rejects 3xx/304 as errors, and overriding it to `() => true` reroutes 4xx/5xx into the SUCCESS interceptor, bypassing envelope error handling. Response headers are lowercase-normalized: `err.response.headers['Retry-After']` is undefined; use `'retry-after'`.
**Safe change:** Set an explicit `timeout` and decide the code literal via `transitional.clarifyTimeoutError`, matching retry logic to it; use `signal: AbortSignal.timeout(ms)` for a hard deadline; branch `err.response` → server replied, `err.request` → no reply, else config bug; never touch `validateStatus` without auditing both interceptor arms.

## cors-allowlist-mirror — HIGH · config-elsewhere
**Contract:** `cors({ origin: process.env.FRONTEND_URL, credentials, allowedHeaders, exposedHeaders })` must mirror exactly what browser code sends and reads — enforcement happens only in the browser, never in server tests.
**Detect:** `cors({`, `FRONTEND_URL`, `allowedHeaders`, `exposedHeaders`, `credentials: true`, `withCredentials`, `Access-Control-`
**Ships green, breaks:** `FRONTEND_URL` unset → `origin: undefined` → the cors package falls back to `*`: silently allows EVERY origin AND breaks all credentialed requests (browsers reject `Access-Control-Allow-Origin: *` with credentials). A comma-joined multi-origin string matches nothing — must be an array or function. If `allowedHeaders` is omitted, cors reflects the request's headers (permissive); once it's explicitly set, adding a new client header without updating it fails ONLY the browser preflight — curl, Postman, and server-to-server all pass. Custom response headers are invisible to client JS without `exposedHeaders`. Errors thrown before the cors middleware produce responses without CORS headers — the browser shows "CORS error", masking the real 500. Cookies need BOTH server `credentials: true` and axios `withCredentials: true`; each alone ships green.
**Safe change:** Fail boot if `FRONTEND_URL` is unset; origins as array/function; treat `allowedHeaders`/`exposedHeaders` as part of every header-adding change on either side; mount cors first; verify with a real browser preflight, not curl.

## rate-limit-trust-proxy — HIGH · trust-invariant
**Contract:** express-rate-limit buckets by `req.ip`, which Express computes from `X-Forwarded-For` according to `app.set('trust proxy', ...)` — the limiter key is only as honest as the declared proxy-hop count.
**Detect:** `app.set('trust proxy'`, `rateLimit(`, `keyGenerator`, `X-Forwarded-For`, `ipKeyGenerator`
**Ships green, breaks:** `trust proxy: true` → `req.ip` is the LEFTMOST `X-Forwarded-For` entry, fully attacker-controlled — every request can present a fresh IP, bypassing the limiter (fail-open on login brute force); express-rate-limit v7 only console-logs `ERR_ERL_PERMISSIVE_TRUST_PROXY` and keeps serving. Unset behind nginx/Cloudflare → `req.ip` is the proxy's IP → the entire userbase shares ONE bucket → production-wide 429s that dev never reproduces (`ERR_ERL_UNEXPECTED_X_FORWARDED_FOR`). A custom `keyGenerator: (req) => req.ip` without IPv6 masking lets one user rotate a /64 for fresh buckets (use the exported `ipKeyGenerator` helper).
**Safe change:** Set `app.set('trust proxy', 1)` with the exact hop count — never `true`; re-count hops whenever proxy topology changes; treat ERL console errors as failures, not noise; smoke-test `req.ip` from outside the proxy after deploy.

## route-path-client-rendezvous — MED · rendezvous-string
**Contract:** axios/fetch URL strings in the client and Express mount + route strings on the server meet only at runtime — no compiler or test links them.
**Detect:** `axios.(get|post|put|delete)('/`, `baseURL`, `fetch('/api`, `app.use('/api`, `router.(get|post)('/`, SPA catch-all `app.get('/{*splat}'`
**Ships green, breaks:** Renaming a mount (`/api/v1` → `/api/v2`) or route segment 404s every call site — and when an envelope-unwrapping interceptor swallows instead of rethrowing, callers get `undefined` and render empty states with no error. An SPA catch-all serving index.html turns API-path typos into 200 `text/html` responses that fail JSON parsing far from the cause. Express defaults are case-insensitive and trailing-slash-tolerant — enabling `strict routing`/`case sensitive routing` changes which existing client strings still match.
**Safe change:** Centralize client paths in one api module (no inline URL literals); grep both repos for the old path before any rename; mount an API-scoped 404 (JSON envelope) before the SPA catch-all so `/api/*` never falls into index.html; prefer additive versioned mounts over in-place renames.
