// apps/web-next/src/server/trpc-context.ts // SERVER-ONLY: builds the per-request tRPC context (audit finding A11). // // Extends the shared `createTrpcContext` (which derives `clientIp`, B2) with: // - the authenticated user, resolved server-side from the session cookie via // the auth feature's IAuthenticationService.validateSession (never from // client input), plus a role snapshot for role-gated procedures (B7/A1); // - the boot-time compliance bindings (consent factory + DSR binding) so // the mounted consent/dsr routers are live instead of dead stubs. import "reflect-metadata"; import { getPayload } from "payload"; import config from "@repo/core-cms"; import { createTrpcContext, type TrpcSessionUser, } from "@repo/core-shared/trpc/context"; import { authContainer } from "@repo/auth/di/container"; import { AUTH_SYMBOLS } from "@repo/auth/di/symbols"; import { bindAll, getComplianceBindings, resolveBindingMode, } from "./bind-production"; /** * Structural view of IAuthenticationService — only the method the context * needs. Resolved from the auth container so production requests hit the * same denylist-aware service instance that sign-in/sign-out use (B5). */ type SessionValidator = { validateSession(token: string): Promise<{ user: { id: string } }>; }; /** * Cookie names carrying the session token: "payload-token" is written by the * production AuthenticationService; "session" (SESSION_COOKIE) by the * dev-seed MockAuthenticationService. */ const SESSION_COOKIE_NAMES = ["payload-token", "session"] as const; function parseCookieHeader(cookieHeader: string): Map { const map = new Map(); for (const part of cookieHeader.split(";")) { const eqIdx = part.indexOf("="); if (eqIdx === -1) continue; const name = part.slice(0, eqIdx).trim(); const value = part.slice(eqIdx + 1).trim(); if (name) map.set(name, value); } return map; } /** * Role snapshot for the authenticated user. The auth entity model carries no * role, so production reads it from the users collection; dev seed has no * Payload and yields no roles (admin-gated procedures are production-only). */ async function resolveRoles(userId: string): Promise { if (resolveBindingMode() !== "production") return []; const resolvedConfig = await config; const payload = await getPayload({ config: resolvedConfig }); const doc = (await payload.findByID({ collection: "users" as never, id: userId, overrideAccess: true, })) as { role?: unknown }; return typeof doc.role === "string" && doc.role.length > 0 ? [doc.role] : []; } /** * Resolve the authenticated user from the request's session cookie. * Returns null for anonymous/invalid/expired sessions — createTrpcContext * treats resolver failures as anonymous, and procedures gate with * UNAUTHORIZED/FORBIDDEN as needed. */ async function resolveUser(req: Request): Promise { const cookieHeader = req.headers.get("cookie"); if (!cookieHeader) return null; const cookies = parseCookieHeader(cookieHeader); const validator = authContainer.get( AUTH_SYMBOLS.IAuthenticationService, ); for (const name of SESSION_COOKIE_NAMES) { const token = cookies.get(name); if (!token) continue; try { const { user } = await validator.validateSession(token); return { id: user.id, roles: await resolveRoles(user.id) }; } catch { // invalid/expired/revoked token under this cookie name — try the next } } return null; } /** * Per-request tRPC context for web-next. Ensures DI is bound, then threads * the server-derived fields + compliance bindings into every procedure. */ export async function createWebNextTrpcContext(req: Request) { await bindAll(); const { consentFactory, dsrBinding } = await getComplianceBindings(); const base = await createTrpcContext(req, { resolveUser }); return { ...base, consentFactory, dsrBinding }; } export type WebNextTrpcContext = Awaited< ReturnType >;