closes the phase-2 hardening list main.ts has been advertising since
#122 (helmet + cors + csrf). two new middlewares + an alignment
pass so every bff error follows a single response contract.
structured error filter:
global ExceptionFilter (registered via app.useGlobalFilters at
the top of bootstrap()) normalises every 4xx/5xx response to:
{ error: { code, message, traceId } }
- code: stable token the spa can switch on. either explicit on
the HttpException's response object
(UnauthorizedException({ code: 'unauthenticated', message }))
or derived from status. 500s always 'internal'.
- message: safe text. 500s NEVER leak the underlying exception
(full message + stack go to pino's err: field, never to the
response body).
- traceId: otel trace id for cross-correlation.
exported errorResponse(code, message) helper for raw-middleware
callers (csrf, rate-limit) so the envelope is identical whether
the response came through nest's filter or a short-circuit
middleware.
rate limiting:
express-rate-limit mounted after the session middleware.
- dynamic max per request: 10/min on /api/auth/login + /api/auth/
callback (RATE_LIMIT_AUTH_PER_MINUTE), 120/min elsewhere
(RATE_LIMIT_PER_MINUTE).
- bucket key = session id when auth, remote ip otherwise. rotating
sessions doesn't dodge the limit; auth'd users get per-account
fairness regardless of source ip.
- /api/health skipped. orchestrator polls don't burn user quota.
- 429 uses the shared errorResponse envelope.
- in-memory store. single-instance v1 per adr-0015. redis-backed
store is one config arg the day we scale out horizontally.
alignment pass on existing error responses:
- csrf middleware used to return { error: 'csrf' }. now uses
errorResponse('csrf', 'CSRF token missing or invalid').
- /auth/me used to res.status(401).json({ error: 'unauthenticated' })
directly. now throws UnauthorizedException({ code: ...,
message: ... }) so the filter formats it. identical shape on
the wire.
type-resolution fix (transitive):
@types/express@4.17.25 was being pulled in by http-proxy-middleware
(nx's webpack-dev-server). express-rate-limit's .d.ts files import
'express', and tsc was matching the v4 copy — causing Request type
mismatches with our v5-based code. added "@types/express": "^5.0.6"
to pnpm.overrides so the workspace pins one version everywhere.
specs:
- 199/199 portal-bff (was 174 before; +25 specs across
StructuredErrorFilter, rate-limit middleware, csrf alignment,
/me alignment).
- feature-auth 28/28, portal-shell 34/34 unchanged.
- clean-env repro: env -u every config var → 261/261 pass.
out of scope:
- redis-backed rate-limit store (single-instance v1).
- per-user rate-limit overrides (admin / service accounts).
- csp fine-tuning when caddy serves the static spas.