Skip to main content

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

  1. AuthMiddleware → Guards: guards read req.user. If middleware ran second, guards would fail on every request.
  2. LoggingInterceptor → ApiResponse/ErrorHandling: both downstream interceptors include requestId in their envelopes.
  3. 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). ApiResponseInterceptor detects res.headersSent and bows out.
  • POST /webhooks/stripe — uses @Res() + @RawBodyRequest so Stripe's signature validator can read the raw Buffer. 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.