EmofyEmofy Developers
Architecture

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:

  1. fetches Better Auth's public keys via createRemoteJWKSet from BETTER_AUTH_URL/.well-known/jwks.json;
  2. verifies the signature, issuer (BETTER_AUTH_URL), and audience (convex);
  3. rejects EMA tokens — any payload carrying an appId claim is refused here, so an EMA token can never be used as a user token;
  4. 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.

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.

Next

On this page