The project API is a single Express app assembled in apps/server/app.ts. Mount order is the contract — this page follows it top to bottom.
Before the app: boot-time gates
Two checks can stop the process before it ever listens:
| Gate | Code | Behaviour |
|---|---|---|
| Environment validation | apps/server/config/env.ts | Zod-validates process.env; prints every offending key and process.exit(1) |
| Production CORS allowlist | middlewares/cors.middleware.ts → validateProdCorsConfig() | Refuses to boot in production without an explicit origin allowlist |
app.set('trust proxy', env.TRUST_PROXY) is applied only when TRUST_PROXY is set. Behind Traefik this matters: without it every client shares the proxy IP and collapses into one rate-limit bucket. With it set and no proxy in front, clients can forge X-Forwarded-For. Opt in deliberately.
The chain
| # | Middleware | File | What it does |
|---|---|---|---|
| 1 | morgan('dev') | — | Console request log. Skipped when NODE_ENV=test |
| 2 | requestId | middlewares/request-id.middleware.ts | Correlation id, echoed as x-request-id and carried into every log line and error envelope |
| 3 | requestLogger | middlewares/request-logger.ts | Meters the request, and on finish pushes one line into the shared LogBuffer + shield telemetry |
| 4 | helmet() | — | Security headers |
| 5 | cors(delegate) | middlewares/cors.middleware.ts | Origin allowlist read per request from the server.cors settings group, falling back to CORS_ORIGINS |
| 6 | globalLimiter | middlewares/rate-limit.ts | Built-in scope limiter (global / auth / rest / edge) |
| 7 | customRulesLimiter | middlewares/custom-rate-limit.ts | Operator-defined rules, most-restrictive-wins |
| 8 | Better Auth routes | auth/routes.ts | Owns /api/auth/*; mounted before body parsers because it reads the raw body |
| 9 | Body parsers | — | urlencoded, cookieParser, express.raw(), express.json() |
| 10 | Studio | — | /studio Next handler, /console/* → 308 redirect |
| 11 | Public bucket static | — | Only S3_PUBLIC_BUCKET is served statically; private objects require the authenticated endpoint |
| 12 | scopePerRequest(container) | Awilix | Creates the per-request DI scope on req.container |
| 13 | Controllers | controllers/*.controller.ts | Auto-loaded by awilix-express's loadControllers |
| 14 | /api/health-check | app.ts | SELECT 1; 200 with db: "connected" or 503 |
| 15 | /api/docs | config/openapi.ts | Swagger UI |
| 16 | 404 | app.ts | app.all('*') forwards new HttpException('Page not found', 404, 'NOT_FOUND') into the error handler |
| 17 | Error handler | middlewares/api-error.middleware.ts | Last. Normalizes every error into one envelope |
Ordering is load-bearing in two places: Better Auth must precede the body parsers, and the error handler must stay last so no forwarded error escapes production masking.
Route-level guards
These are not global — controllers opt in with @before([...]).
| Guard | File | Rule |
|---|---|---|
authMiddleware | middlewares/auth.middleware.ts | Validates a Better Auth session (cookie or bearer/JWT), reloads the full Prisma user onto req.user |
optionalAuthMiddleware | same | Same, but anonymous requests continue |
requireRole(...roles) | middlewares/require-role.ts | Runs after auth; 401 when req.user.role is not listed |
adminGuard / adminOnly | middlewares/platform-admin.middleware.ts | Guards every /api/admin route |
bucketTokenGuard(perm) | middlewares/bucketToken.middleware.ts | Verifies a bucket bearer token against :slug for read/write/delete |
bucketCors | middlewares/bucketCors.middleware.ts | Per-bucket CORS rules for storage routes |
edgeInvokeGuard | middlewares/edge-invoke-guard.ts | Gates public edge-function invocation |
validateRequest | middlewares/validate-request.ts | Turns express-validator results into a validation error response |
adminGuard: two ways in
// 1. Console proxy: X-Platform-Token matched against PLATFORM_SERVICE_TOKEN
// → synthetic { id: 'platform', role: 'ADMIN', platform: true }
// 2. Normal session with role ADMINA present-but-wrong X-Platform-Token is rejected immediately — it never falls through to session auth. Comparison uses lib/safe-equal.ts (constant time).
edgeInvokeGuard
Allows the call when any of these hold, else 401:
EDGE_PUBLIC_INVOKE_ENABLED=true, or- the request is authenticated, or
x-motherbase-edge-token(orx-edge-invoke-token) matchesEDGE_INVOKE_TOKEN.
Rate limiting in two layers
Built-in scopes (middlewares/rate-limit.ts) — window and per-scope maxima both live-tunable through the security.rateLimit settings group:
| Scope | Env seed | Default |
|---|---|---|
| global | RATE_LIMIT_GLOBAL_MAX | 1000 |
| auth | RATE_LIMIT_AUTH_MAX | 20 |
| REST read | RATE_LIMIT_REST_READ_MAX | 300 |
| REST write | RATE_LIMIT_REST_WRITE_MAX | 120 |
| edge | RATE_LIMIT_EDGE_MAX | 60 |
Buckets key on user:<id> when authenticated, otherwise ip:<addr>.
Every knob in the security.rateLimit group applies live. The maximums are read through a
function on each request; the window (windowMin, seeded from RATE_LIMIT_WINDOW_MIN)
cannot be, because express-rate-limit reads windowMs once at construction — so changing
it rebuilds the limiters instead. Rebuilding resets the counters, which is the honest
behaviour: a count of 40 means nothing once the period it was counted over has changed.
A save that leaves the window alone does not rebuild.
Counters live in the process, so limits are per API instance. One project runs one API
container, which is the deployment this is written for; if you run several replicas of the
same project behind a load balancer, each enforces its own share of the limit. The same
applies to the custom-rule store below, the log buffer and the shield telemetry — but not
to scheduled work, which takes database leases. Every connection is tagged
motherbase-api:<instanceId>, so the Shield report counts live instances and warns when
there is more than one. See known issues.
Keying on ip:<addr> also depends on TRUST_PROXY being set correctly behind a reverse
proxy — leave it unset behind Traefik and every client shares the proxy's IP in a single
bucket, so one caller can exhaust the limit for everyone.
Custom rules (middlewares/custom-rate-limit.ts + lib/rate-limit-rules.ts) — operator-defined, loaded from the security.rateLimitRules group, evaluated most-restrictive-wins, backed by an in-memory FixedWindowStore. Identity resolution (user id / role / API-key hash) runs only when an enabled rule actually keys on it, so purely IP-based rule sets add no per-request session lookup. Roles are read live from the database, never trusted from the token. Invalid saved rules are ignored and the previous set is kept, so a bad save can never switch limiting off.
Realtime path
Socket.IO is mounted at path /ws on the same HTTP server, with its CORS origin list derived from CORS_ORIGINS (in production, an empty allowlist denies cross-origin sockets). An io.use handshake middleware validates an optional bearer token via Better Auth and, on success, attaches socket.data.user = { id, role }. Connections without a token are accepted as anonymous.
Error envelope
Every error — thrown, forwarded, or a 404 — exits through createApiErrorHandler:
{ "status": 0, "code": "NOT_FOUND", "message": "Page not found", "requestId": "…" }status is literally 0 in the body — the HTTP status is the response's own. requestId
is present whenever the correlation middleware set one.
normalizeApiError decides the public message:
ApplicationErrorwithexpose: true→ its own message andcode.- A recognised database constraint failure → a fixed sentence keyed off the SQLSTATE. No schema name, value, or SQL text reaches the client.
- Anything else → the internal message in development, a generic message in production.
Errors are logged when they are unknown or >= 500; the log line also lands in the LogBuffer with source: 'http'.
Shutdown
installProcessSafety (lib/process-safety.ts) keeps an unhandled rejection from killing the process and drains in flight requests on restart, closing in order: bucket-deletion scheduler, realtime outbox scheduler, Prisma, the RLS pool, and Socket.IO.