Request lifecycle
Phase by phase
1. AuthMiddleware (src/auth/middleware/auth.middleware.ts)
Reads the better-auth session cookie. If valid, sets req.user = { id, email, role, ... } and req.session. If absent, sets req.user = null. Falls back to Authorization: Bearer <token> and validates against JWKS.
Does not throw on missing auth — that's the guard's job. This middleware just enriches the request.
2. AuditLogMiddleware (src/logging/middleware/audit-log.middleware.ts)
Attaches res.on('finish', ...) to enqueue an AuditLogEntry after the response completes. Queue flushes via AuditLoggerService.
3. LoggingInterceptor (src/logging/interceptors/)
Generates req.requestId (UUID) at the start of the request. Logs final duration after the handler returns.
Order matters: this must run first among interceptors. The other two read requestId.
4. ApiResponseInterceptor (src/common/interceptors/api-response.interceptor.ts)
Wraps the handler's return in:
{
requestId, timestamp, status: 'success', statusCode, data, error: null,
pagination?: { cursor, hasMore, limit } // when handler returns { data, pagination }
}
Skips wrapping if res.headersSent is true. That's how /api/auth/* (which uses @Res() + better-auth's toNodeHandler) escapes the envelope.
5. ErrorHandlingInterceptor
Uses RxJS catchError. Catches thrown HttpException (and subclasses), maps to:
{
requestId, timestamp, status: 'error', statusCode, data: null,
error: { code, message, details? }
}
Order matters: must come AFTER ApiResponseInterceptor. The error interceptor fires only on thrown exceptions; the response interceptor fires only on successful returns. Same stream chain, different paths.
6. Guards
PlatformAdminGuard, OrgAdminGuard, OrgMemberGuard. Each reads req.user.role — which was set in step 1.
Guards do NOT call the DB. They trust the session payload. This is fast and correct as long as session tokens are short-lived enough that a role change visibly takes effect within token refresh window (10h).
7. ValidationPipe
Global class-validator pipe (new ValidationPipe({ whitelist: true, transform: true })). DTOs validated. Excess properties stripped. On failure → 400 BAD_REQUEST with details.
8. Handler
Your controller method. Has full access to req.user, req.session, req.requestId.
Why order is load-bearing
- AuthMiddleware → Guards: guards read
req.user. If middleware ran second, guards would fail on every request. - LoggingInterceptor → ApiResponse/ErrorHandling: both downstream interceptors include
requestIdin their envelopes. - ApiResponse → ErrorHandling: the error interceptor uses
catchError, which only fires for thrown exceptions, not successful responses. If you swap them, errors get double-wrapped or wrapped weirdly.
If you reorder these in main.ts, things break in non-obvious ways. There's a comment in the file saying so. Read it.
Exceptions to the chain
/api/auth/*(better-auth) — uses@Res()(Express response passthrough).ApiResponseInterceptordetectsres.headersSentand bows out.POST /webhooks/stripe— uses@Res()+@RawBodyRequestso Stripe's signature validator can read the rawBuffer. No envelope.
In dev, what fires when
Set LOG_LEVEL=debug to see:
[req-uuid] AuthMiddleware: user=alice@example.com role=org_member
[req-uuid] LoggingInterceptor: GET /agents 200 87ms
Combined with PostHog audit log, that's enough to debug any 99% case.