Request Lifecycle
One authenticated request traced end to end through the backend — from JWT verification to the response envelope.
This page follows a single request the whole way through the backend so the guard and interceptor chain stops being an abstract list and becomes a story.
Our example: a teacher's web client calls
POST /api/v1/tasks
Authorization: Bearer <jwt>
X-Org-Id: org_8f2k...
Idempotency-Key: 2c91-...
Content-Type: application/json
{ "title": "Field trip permission slip", "dueDate": "2026-06-20" }The journey
HTTP request
│
├─▶ Middleware: CorrelationId → EmaRateLimit → PrdTag
│
├─▶ Guards (short-circuit on failure):
│ 1 CustomThrottler IP within the public rate budget?
│ 2 JwtAuth verify JWT, attach request.user
│ 3 UserRateLimit this user within their per-user budget?
│ 4 Org authenticated non-super-admin has org context?
│ 5 MemberStatus caller still an active member? (cached)
│ 6 Roles @Roles satisfied? (legacy)
│ 7 Permission @RequirePermission "tasks:create" satisfied?
│ 8 Relationship @DataClass row access ok?
│ 9 Consent consent present for sensitive child data?
│ 10 EmaProxy EMA init-data valid? (no-op here)
│
├─▶ ValidationPipe: body → CreateTaskDto (whitelisted, transformed)
│
├─▶ Controller → Service → OrgScopedRepository (orgId auto-applied)
│ │
│ └─▶ Postgres (INSERT) + outbox event
│
├─▶ Interceptors (response side):
│ OrgScope → ...Audit → RateLimitHeaders → Transform (envelope)
│
└─▶ HTTP response { success | data, meta }Step by step
1. Middleware
CorrelationIdMiddleware assigns a request ID (surfaced later in meta.requestId
and logs). EmaRateLimitMiddleware and a PRD-tagging middleware also run here.
2. Authentication — JwtAuthGuard + the JWT strategy
JwtAuthGuard (a Passport AuthGuard("jwt")) delegates to the JWT strategy
(modules/identity/strategies/jwt.strategy.ts), which:
- fetches Better Auth's public keys via
createRemoteJWKSetfromBETTER_AUTH_URL/.well-known/jwks.json; - verifies the signature, issuer (
BETTER_AUTH_URL), and audience (convex); - rejects EMA tokens — any payload carrying an
appIdclaim is refused here, so an EMA token can never be used as a user token; - loads the user from the auth DB (exists, email-verified, super-admin flag) and, for org-scoped users, checks a Redis-cached org status (suspended / deleted → rejected).
The result is attached as request.user:
interface UserPayload {
userId: string;
orgId: string | null;
orgRole: OrganizationRole | null;
additionalRoles: OrganizationRole[];
orgSlug: string | null;
isSuperAdmin: boolean;
}Routes marked @Public() skip this guard entirely.
3–5. Rate, org, and membership gates
UserRateLimitGuard enforces the per-user budget. OrgGuard ensures an
authenticated non-super-admin actually has org context (else 403).
MemberStatusGuard checks — against a 300s Redis cache — that the caller is
still an active member of the org, so a just-removed member loses write access
quickly.
6–7. Authorization — roles then permissions
RolesGuard handles legacy @Roles(...) declarations. PermissionGuard is the
modern path: our route is decorated @RequirePermission("tasks:create"), so the
guard calls canWithRoles(orgRole, additionalRoles, "tasks", "create") from the
shared @emofy/types SSOT. Super-admins bypass both guards. See
Authorization for the full model.
8–9. Relationship & consent
For child-data routes (@DataClass), RelationshipGuard checks row-level
access (e.g. this guardian is linked to this child) and ConsentGuard checks
that the required guardian consent exists. Our task-create route is not
child-data, so these pass through.
10. Validation
The ValidationPipe transforms the JSON body into a CreateTaskDto. Unknown
fields are rejected (forbidNonWhitelisted); @IsPrefixedId() and friends
enforce field shapes. A bad body produces a 400 with a flattened details
array.
Handler & data layer
The controller calls the service, which uses an OrgScopedRepository. The
repository automatically applies orgId from the request context to the
insert — there is no way to forget tenant scoping. The write and its side-effect
(an outbox event that later fans out a notification) commit together.
Response interceptors
On the way out, OrgScopeInterceptor, the audit interceptors, and
RateLimitHeadersInterceptor run, then TransformInterceptor wraps the result
in the surface's envelope. For the developer (v1) surface that is:
{
"data": { "id": "task_h4n2k9x...", "title": "Field trip permission slip" },
"meta": { "requestId": "req-2c91...", "timestamp": "2026-06-17T12:00:00.000Z" }
}Idempotency
Because the request carried an Idempotency-Key, a replay of the same key
returns the original result with an Idempotency-Replayed header instead of
creating a second task (PRD-224). See
API conventions.
Errors
Anything thrown is caught by FamilyExceptionFilter (family domain) or
AllExceptionsFilter (everything else), shaped into the error envelope, and —
for 5xx — reported to Sentry with the correlation ID.