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>
This commit is contained in:
111
apps/web-next/src/server/trpc-context.ts
Normal file
111
apps/web-next/src/server/trpc-context.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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>
|
||||
>;
|
||||
Reference in New Issue
Block a user