/mir-backend-node-express · Make It Right (Express)
Bottom tier of the chain: mir-backend (generic gates) → mir-backend-node (V8/Node event-loop model) → this (Express library mechanics). Run the gates first; load the Node runtime tier for event-loop and process-model concerns; reach for this at Gate 5 (design mechanics), Gate 6 (implementation), and Gate 7 review. Runtime-level concerns (blocking the event loop, worker_threads, unhandled rejections, stream backpressure, AbortController timeouts, heap limits) live in mir-backend-node — not here.
Stack assumed: Express 4.x or 5.x on Node 20+ LTS. Version differences that change behavior are called out explicitly.
The Express footguns AI walks into most
1. Async errors in Express 4 are silently swallowed — Express 5 changes this
This is the single most common Express reliability defect AI ships.
Express 4 does not await route handler return values. If an async handler throws, the error is an unhandled promise rejection — it does not reach your (err, req, res, next) error handler, and the request hangs until the client times out:
// WRONG in Express 4 — thrown error is an unhandled rejection, request hangs
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id); // throws → disappears
res.json(user);
});
Fix: wrap with a helper that forwards rejections to next, or explicitly catch and call next(err):
// Option A — asyncHandler wrapper (e.g. express-async-errors or a 3-line helper)
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
}));
// Option B — explicit catch
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
});
Express 5 awaits returned promises from route handlers and automatically forwards rejections to next — no wrapper needed. State the version in use; the fix differs. Using express-async-errors is a zero-migration shim for Express 4 codebases.
2. Middleware order is the contract — get it wrong and nothing else matters
Express resolves middleware strictly in registration order. The order is not a style choice; it is the execution contract:
body parser
└── request-id / correlation-id logger
└── rate limiter
└── authentication
└── authorization (object-level, per-router)
└── route handlers
└── 404 handler
└── error handler ← MUST be last, MUST have 4 args
Common ordering defects AI introduces:
- Body parser after routes —
req.bodyisundefinedin the handler. - Auth middleware missing from a router — a route added later slips in unauthenticated.
- Error handler not registered last — it never runs.
- Error handler with 3 args (
(req, res, next)) — Express identifies error handlers by arity; 3-arg handlers are treated as regular middleware and skipped for errors.
// WRONG — error handler silently skipped (3 args)
app.use((err, res, next) => { res.status(500).json({ error: err.message }); });
// RIGHT — must have exactly 4 args
app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });
3. No built-in validation — without it, mass assignment and injection slip in
Express puts raw, unvalidated input on req.body / req.query / req.params. AI code that passes these directly to a database or service layer is vulnerable to:
- Mass assignment / overposting — a client sends
{ "isAdmin": true }alongside a legitimate payload; if you spreadreq.bodyinto an ORM object, you've just self-promoted them. - Type coercion surprises — query strings are always strings; passing
req.query.limitto a DB call that expects a number without parsing is a latent bug.
Validate at the route boundary with zod or joi. Define a schema, parse, and use only the parsed output:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
// id, isAdmin, role are NOT here — they're not accepted from the client
});
app.post('/users', asyncHandler(async (req, res) => {
const body = CreateUserSchema.parse(req.body); // throws ZodError on invalid input
const user = await db.createUser(body); // only safe fields reach the DB
res.status(201).json(user);
}));
Catch ZodError in the error handler and return a 400 with field-level detail. Never pass req.body to an ORM model constructor without schema-gating it first.
4. Security defaults are off — add helmet, CORS allowlist, rate limiting
Express ships with no security headers, open CORS, and no rate limiting. The defaults are the attack surface:
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
// Helmet sets CSP, HSTS, X-Frame-Options, etc.
app.use(helmet());
// CORS: explicit allowlist, not '*'
app.use(cors({
origin: ['https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
}));
// Rate limiting: per-IP, before auth/DB work
app.use('/api/', rateLimit({ windowMs: 60_000, max: 100 }));
Do not trust req.ip behind a proxy without app.set('trust proxy', 1) — otherwise rate limiting and logging record the proxy IP, not the real client.
5. Object-level authorization is easy to miss — no structure enforces it
Express has no built-in guard or controller structure. Authentication (who are you?) is easy to add as a single middleware. Authorization (may this user act on this specific resource?) is easy to forget because there's no framework hook that forces it at the route level:
// WRONG — checks auth but not ownership; any authenticated user can read any user's data
app.get('/accounts/:id', requireAuth, asyncHandler(async (req, res) => {
const account = await db.findAccount(req.params.id);
res.json(account); // IDOR: account might belong to a different user
}));
// RIGHT — load the resource, then assert ownership before returning
app.get('/accounts/:id', requireAuth, asyncHandler(async (req, res) => {
const account = await db.findAccount(req.params.id);
if (!account || account.userId !== req.user.id) {
return res.status(404).json({ error: 'Not found' });
}
res.json(account);
}));
Return 404 (not 403) when the resource doesn't belong to the requester — 403 leaks the existence of the resource. Enforce object-level authz at every endpoint that loads an entity by a client-supplied ID.
How this slots into the core pipeline
- Gate 5 (Design): state Express version (4 vs 5), middleware order, validation library, and auth strategy. A missing
asyncHandlerwrapper in Express 4 is a design defect that surfaces as hung requests in production. - Gate 6 (Implementation): every route handler is wrapped (Express 4) or returns a promise (Express 5); validation schema defined before handler body; helmet + cors + rate-limiter registered before routes; object-level authz at every entity-loading route.
- Gate 7 (Review): the reliability-reviewer checks items 1–5 here; specifically verifies error-handler arity and that no route accesses
req.bodybefore body-parser middleware.
Edit boundary (what belongs here vs. above/below)
This module holds ONLY Express library mechanics. Apply the 3-tier placement test before adding anything:
- True for Go/Python/Java too (idempotency, invariants, gates)? → generic core (
mir-backend). - True for every Node framework (blocking the event loop, unhandled rejections, backpressure, heap limits, AbortController)? → runtime tier (
mir-backend-node). - A mechanical footgun of Express itself (async error propagation, middleware arity, no built-in validation, CORS/helmet defaults, IDOR from missing authz)? → here.
- A different Node framework (Fastify, NestJS) → its own
mir-backend-node-<framework>module. A different runtime → its own tier. Never widen this one.
Source: anantbhandarkar/make-it-right — distributed by TomeVault.