Browse documentation

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:

GateCodeBehaviour
Environment validationapps/server/config/env.tsZod-validates process.env; prints every offending key and process.exit(1)
Production CORS allowlistmiddlewares/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

#MiddlewareFileWhat it does
1morgan('dev')—Console request log. Skipped when NODE_ENV=test
2requestIdmiddlewares/request-id.middleware.tsCorrelation id, echoed as x-request-id and carried into every log line and error envelope
3requestLoggermiddlewares/request-logger.tsMeters the request, and on finish pushes one line into the shared LogBuffer + shield telemetry
4helmet()—Security headers
5cors(delegate)middlewares/cors.middleware.tsOrigin allowlist read per request from the server.cors settings group, falling back to CORS_ORIGINS
6globalLimitermiddlewares/rate-limit.tsBuilt-in scope limiter (global / auth / rest / edge)
7customRulesLimitermiddlewares/custom-rate-limit.tsOperator-defined rules, most-restrictive-wins
8Better Auth routesauth/routes.tsOwns /api/auth/*; mounted before body parsers because it reads the raw body
9Body parsers—urlencoded, cookieParser, express.raw(), express.json()
10Studio—/studio Next handler, /console/* → 308 redirect
11Public bucket static—Only S3_PUBLIC_BUCKET is served statically; private objects require the authenticated endpoint
12scopePerRequest(container)AwilixCreates the per-request DI scope on req.container
13Controllerscontrollers/*.controller.tsAuto-loaded by awilix-express's loadControllers
14/api/health-checkapp.tsSELECT 1; 200 with db: "connected" or 503
15/api/docsconfig/openapi.tsSwagger UI
16404app.tsapp.all('*') forwards new HttpException('Page not found', 404, 'NOT_FOUND') into the error handler
17Error handlermiddlewares/api-error.middleware.tsLast. Normalizes every error into one envelope
Warning

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([...]).

GuardFileRule
authMiddlewaremiddlewares/auth.middleware.tsValidates a Better Auth session (cookie or bearer/JWT), reloads the full Prisma user onto req.user
optionalAuthMiddlewaresameSame, but anonymous requests continue
requireRole(...roles)middlewares/require-role.tsRuns after auth; 401 when req.user.role is not listed
adminGuard / adminOnlymiddlewares/platform-admin.middleware.tsGuards every /api/admin route
bucketTokenGuard(perm)middlewares/bucketToken.middleware.tsVerifies a bucket bearer token against :slug for read/write/delete
bucketCorsmiddlewares/bucketCors.middleware.tsPer-bucket CORS rules for storage routes
edgeInvokeGuardmiddlewares/edge-invoke-guard.tsGates public edge-function invocation
validateRequestmiddlewares/validate-request.tsTurns 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 ADMIN

A 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:

  1. EDGE_PUBLIC_INVOKE_ENABLED=true, or
  2. the request is authenticated, or
  3. x-motherbase-edge-token (or x-edge-invoke-token) matches EDGE_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:

ScopeEnv seedDefault
globalRATE_LIMIT_GLOBAL_MAX1000
authRATE_LIMIT_AUTH_MAX20
REST readRATE_LIMIT_REST_READ_MAX300
REST writeRATE_LIMIT_REST_WRITE_MAX120
edgeRATE_LIMIT_EDGE_MAX60

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:

  • ApplicationError with expose: true → its own message and code.
  • 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.