Files
agentic-dev/apps/web-next/src/server/trpc-context.ts
Danijel Martinek 49241845b5 feat(web-next): resolve the session user + live compliance context
The tRPC createContext was () => ({}) — the mounted dsr/consent routers
401'd every call and the dsr singleton stub threw (audit finding A11).
createTrpcContext now accepts an app resolveUser hook; web-next resolves
the session cookie through the auth feature's validateSession (denylist
included) plus a role snapshot, and threads bindProductionDsr/Consent
(or dev-seed) bindings into every request. The dsr router resolves its
binding from ctx.dsrBinding per request instead of a throwing proxy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00

112 lines
4.0 KiB
TypeScript

// 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<string, string> {
const map = new Map<string, string>();
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<string[]> {
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<TrpcSessionUser | null> {
const cookieHeader = req.headers.get("cookie");
if (!cookieHeader) return null;
const cookies = parseCookieHeader(cookieHeader);
const validator = authContainer.get<SessionValidator>(
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<typeof createWebNextTrpcContext>
>;