Middleware lives in two places. Cross cutting pieces that have no Express app knowledge come from packages (@perform/observability, @perform/logger, @perform/security, @perform/errors). Pieces that know about the app’s principals live in apps/core-api/src/middleware/.

Global chain

Built in createApp (apps/core-api/src/app.ts), in this order.

trust proxy

One proxy hop (nginx in front of the process) sets X-Forwarded-For. Without this, req.ip is the proxy for every request and the /v1 limiter puts every trainee in one bucket. The value is 1, not true, so a client cannot forge its own X-Forwarded-For and pick its bucket. It is set before any middleware so every router sees the same req.ip.

helmet and cors

helmet() runs with defaults. cors takes its origin from CORS_ORIGIN: * stays *, anything else is split on commas and trimmed. x-powered-by is disabled.

requestIdMiddleware

From @perform/observability. Uses the incoming x-request-id header when present, otherwise generates a ULID. Sets req.requestId and the X-Request-Id response header. The id ends up in the response envelope and in every HTTP log line.

Body parsers

Four parsers are registered. Each one captures the raw bytes into req.rawBody through the verify hook, because service signatures cover the body bytes and the Hono gateway replays them. The scoped parsers are registered first. express.json skips a request whose body is already parsed, so the global 24mb parser does not re-read a body the 48mb parser handled. The trainee upload route adds its own express.raw({ type: () => true, limit: '15mb' }) inside the router. That parser only sees bodies the global parsers left alone, so a JSON or application/octet-stream upload is still bound by the global limits above.

cookieParser and httpLogger

cookieParser() populates req.cookies. httpLogger is the pino-http instance from @perform/logger. It logs one line per request with requestId, at debug for success, warn for 4xx and error for 5xx, and skips /healthz, /readyz and /metrics.

Rate limiters

Two express-rate-limit instances are created from env and applied per mount. See Rate limiting.

notFoundMiddleware and errorMiddleware

Both come from @perform/errors and are registered last. See Responses and errors.

honoGateway

middleware/hono-gateway.ts bridges Express to the Hono app from @repo/api. For each request under /api it:
  1. Rebuilds the absolute URL from req.protocol, the host header and req.originalUrl.
  2. Copies every request header into a Headers object.
  3. Attaches req.rawBody as the body for methods other than GET and HEAD.
  4. Calls honoApp.fetch(new Request(url, init)).
  5. Copies status and headers back, then writes the body as a buffer.
Set-Cookie is copied with response.headers.getSetCookie() instead of forEach. Headers.forEach joins same name headers with a comma, which is wrong for cookies. An impersonation response sets three cookies at once, and joined they form one malformed cookie the browser drops, so the session silently never changed.
The gateway needs rawBody. A request to /api with a content type that no parser captures (for example multipart/form-data) reaches Hono with an empty body.

Lane guards

requireServiceAuth and replayProtection

From @perform/security. They verify the HMAC signature and the nonce. Full contract on Service auth.

webUserContext(prisma)

middleware/web-context.ts. Runs after requireServiceAuth on every /v1/web route.
  1. Reads x-studio-id and x-user-id. Either missing throws UNAUTHORIZED with missing web user context.
  2. Parses x-user-role. Unknown or missing becomes SUB_COACH.
  3. ensureStudio resolves the organization id to a Studio.id:
    • Looks in the in-memory resolvedStudios map.
    • Finds the studio by externalOrgId.
    • Otherwise finds it by slug. If that studio has no externalOrgId yet it is linked to this organization and renamed.
    • Otherwise creates the studio with defaultContentCategories(null).
  4. Sets req.auth = { studioId, userId, role }.
  5. For OWNER, ensureOwnerCoach upserts a Coach row with role HEAD_COACH keyed by (studioId, externalUserId). The upsert’s update is empty, so an existing row is never changed. Cached in provisionedOwnerCoaches.
  6. touchCoachActivity writes Coach.lastActiveAt at most once per 5 minutes per coach, without awaiting. It powers the last activity column on the team page.
The practical effect is that a studio and its owner’s coach row appear the first time the web app calls the API for that organization, even if the explicit provisioning call never ran.

authenticate(verify)

middleware/auth.ts. Bearer lane only. Extracts the token from Authorization: Bearer, calls the injected SessionVerifier, and sets req.auth. No token throws UNAUTHORIZED with missing bearer token. A verifier that returns null throws UNAUTHORIZED with invalid session. The file also exports the types the rest of the app uses:

authenticateTrainee(secret, prisma)

middleware/trainee-auth.ts. Verifies the trainee token with verifyTraineeToken, sets req.trainee, then:
  • Rejects every non GET request from a preview token with FORBIDDEN and details.reason = 'PREVIEW_READ_ONLY'.
  • Checks the studio is not archived (Studio.deletedAt), cached for 30 seconds. Trainee tokens never expire, so archiving a studio has to reach sessions that were already issued. An archived studio throws UNAUTHORIZED with studio no longer exists.
  • For real sessions, awaits rememberTraineeTimezone with the x-timezone header and swallows its errors.

requireTraineeAppAccess(loadAccessState)

Same file. Applied to trainee data routes. It loads a small access state for the client (cached 30 seconds, at most 5,000 entries, cleared when full) and:
  • Throws UNAUTHORIZED with account no longer exists when the client or its studio is deleted.
  • Throws FORBIDDEN with details.reason = 'APP_LOCKED' when traineeAppLocked is true.
A trainee is locked when the subscription is frozen (planFrozenOn set) or has ended, unless the coach left appAccessWhileFrozen on. The end date is coverageEndsOn(endsOn, subscriptions), so a plan queued behind the running one keeps the app open.

requirePartnerKey(store)

modules/partner/partner-auth.ts. Described on Route lanes. Used by /v1/partner and /v1/automation.

Authorization guards

requireRole(...roles)

middleware/require-role.ts. Needs req.auth. Without it: UNAUTHORIZED. With a role not in the list: FORBIDDEN with insufficient role. Called with no roles it only checks that the caller is authenticated.

Coach access

middleware/coach-access.ts resolves what a coach may reach from their Coach row. It exports: requireAssignedClient answers 404, the same answer as for a client in another studio, so the guard never reveals which ids exist. The rules are on Roles and permissions.

Request type augmentation

apps/core-api/src/types/request.d.ts augments Express’s Request with requestId, rawBody, auth and service. req.trainee and req.partner are not in the global augmentation. Handlers read them through the TraineeRequest and PartnerRequest types or the partnerOf(req) helper.

Async handlers

Express 5 forwards a rejected promise from an async handler to the error middleware. Controllers are plain async (req, res) => { ... } functions with no wrapper and no try/catch. Middleware written as non-async functions (the guards above) must call next(err) themselves, which is why they use .then(() => next()).catch(next).