@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 increateApp (apps/core-api/src/app.ts), in this order.
trust proxy
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 intoreq.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
Twoexpress-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:
- Rebuilds the absolute URL from
req.protocol, thehostheader andreq.originalUrl. - Copies every request header into a
Headersobject. - Attaches
req.rawBodyas the body for methods other thanGETandHEAD. - Calls
honoApp.fetch(new Request(url, init)). - 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.
- Reads
x-studio-idandx-user-id. Either missing throwsUNAUTHORIZEDwithmissing web user context. - Parses
x-user-role. Unknown or missing becomesSUB_COACH. ensureStudioresolves the organization id to aStudio.id:- Looks in the in-memory
resolvedStudiosmap. - Finds the studio by
externalOrgId. - Otherwise finds it by
slug. If that studio has noexternalOrgIdyet it is linked to this organization and renamed. - Otherwise creates the studio with
defaultContentCategories(null).
- Looks in the in-memory
- Sets
req.auth = { studioId, userId, role }. - For
OWNER,ensureOwnerCoachupserts aCoachrow with roleHEAD_COACHkeyed by(studioId, externalUserId). The upsert’supdateis empty, so an existing row is never changed. Cached inprovisionedOwnerCoaches. touchCoachActivitywritesCoach.lastActiveAtat most once per 5 minutes per coach, without awaiting. It powers the last activity column on the team page.
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
GETrequest from a preview token withFORBIDDENanddetails.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 throwsUNAUTHORIZEDwithstudio no longer exists. - For real sessions, awaits
rememberTraineeTimezonewith thex-timezoneheader 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
UNAUTHORIZEDwithaccount no longer existswhen the client or its studio is deleted. - Throws
FORBIDDENwithdetails.reason = 'APP_LOCKED'whentraineeAppLockedis true.
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 plainasync (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).