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>
112 lines
4.0 KiB
TypeScript
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>
|
|
>;
|