diff --git a/apps/web-next/package.json b/apps/web-next/package.json index 17a7659..6b01ae5 100644 --- a/apps/web-next/package.json +++ b/apps/web-next/package.json @@ -17,7 +17,10 @@ "@repo/auth": "workspace:*", "@repo/blog": "workspace:*", "@repo/core-api": "workspace:*", + "@repo/core-audit": "workspace:*", "@repo/core-cms": "workspace:*", + "@repo/core-consent": "workspace:*", + "@repo/core-dsr": "workspace:*", "@repo/core-shared": "workspace:*", "@repo/core-trpc": "workspace:^", "@repo/marketing-pages": "workspace:*", diff --git a/apps/web-next/src/app/api/trpc/[trpc]/route.ts b/apps/web-next/src/app/api/trpc/[trpc]/route.ts index 09bc884..ac2f8c9 100644 --- a/apps/web-next/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web-next/src/app/api/trpc/[trpc]/route.ts @@ -1,15 +1,17 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@repo/core-api"; -import { createTrpcContext } from "@repo/core-shared/trpc/context"; +import { createWebNextTrpcContext } from "../../../../server/trpc-context"; const handler = async (req: Request) => { return fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, - // Threads server-derived fields (clientIp from proxy headers — see the - // trust caveat in core-shared/trpc/context.ts) into every procedure (B2). - createContext: () => createTrpcContext(req), + // Real per-request context (A11): server-derived clientIp (B2, trust + // caveat in core-shared/trpc/context.ts), the authenticated user resolved + // from the session cookie (B7), and the consent/dsr bindings that make + // the mounted compliance routers live. + createContext: () => createWebNextTrpcContext(req), }); }; diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index 216b7d5..8751115 100644 --- a/apps/web-next/src/server/bind-production.test.ts +++ b/apps/web-next/src/server/bind-production.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +// bindAllProduction wires core-audit, which fails fast in NODE_ENV=production +// without a pseudonym salt (by design). Provide one for the whole suite. +process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production"; + vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) })); vi.mock("payload", () => ({ getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })), diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index 1e97eba..e9c4f71 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -21,6 +21,17 @@ import { NoopRateLimit, type RateLimitBudget, } from "@repo/core-shared/rate-limit"; +import { bindAudit, type IAuditLog } from "@repo/core-audit"; +import { + bindProductionConsent, + bindDevSeedConsent, + type ConsentFactory, +} from "@repo/core-consent"; +import { + bindProductionDsr, + bindDevSeedDsr, + type DsrBinding, +} from "@repo/core-dsr"; import { authManifest } from "@repo/auth"; import { bindProductionBlog } from "@repo/blog/di/bind-production"; import { bindProductionAuth } from "@repo/auth/di/bind-production"; @@ -44,6 +55,45 @@ let resolvedTracer: ITracer | null = null; let resolvedLogger: ILogger | null = null; let resolvedQueue: IJobQueue | null = null; +/** + * Compliance bindings constructed once per boot (audit finding A11): the + * consent factory and DSR binding that the tRPC createContext threads into + * every request so the mounted consent/dsr routers are live. `auditLog` is + * present only on the production path. + */ +export type ComplianceBindings = { + consentFactory: ConsentFactory; + dsrBinding: DsrBinding; + auditLog?: IAuditLog; +}; + +let complianceBindings: ComplianceBindings | null = null; + +export type BindingMode = "production" | "dev-seed"; + +/** Env → binding mode, mirroring bindAll()'s resolution rules. */ +export function resolveBindingMode(): BindingMode { + if (process.env.USE_DEV_SEED === "false") return "production"; + if (process.env.USE_DEV_SEED === "true") return "dev-seed"; + if (process.env.NODE_ENV === "production") return "production"; + return "dev-seed"; +} + +/** + * Resolve the boot-time compliance bindings, running bindAll() first if + * needed. Called by the app's tRPC createContext on every request (cheap + * after the first call — bindAll is memoized). + */ +export async function getComplianceBindings(): Promise { + await bindAll(); + if (!complianceBindings) { + throw new Error( + "compliance bindings missing after bindAll() — binder did not construct them", + ); + } + return complianceBindings; +} + /** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */ function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } { if (resolvedTracer && resolvedLogger) { @@ -108,11 +158,28 @@ export async function bindAllProduction(): Promise { const { queue } = await resolveJobsProduction(); const resolvedConfig = await config; + // Compliance cores (A6/A11): the audit log fans into the Payload + // `audit-logs` collection + stdout; consent + DSR bindings share it so + // every grant/withdraw/export/delete leaves an audit trail. + const { auditLog } = bindAudit(sharedContainer, { + payloadConfig: resolvedConfig, + }); + const { consentFactory } = bindProductionConsent({ + config: resolvedConfig, + auditLog, + }); + const dsrBinding = bindProductionDsr({ config: resolvedConfig, auditLog }); + complianceBindings = { consentFactory, dsrBinding, auditLog }; + const ctx: BindProductionContext = { config: resolvedConfig, tracer, logger, queue, + auditLog, + // Enables the anonymous→authenticated consent migration inside the auth + // sign-up use case (audit finding A12). + consentFactory, // Real limiter in production (audit finding A4/B3): budgets come from the // feature manifests, so manifest edits change enforcement without touching // this file. In-memory ⇒ per-process counters; multi-instance deployments @@ -136,10 +203,17 @@ export async function bindAllDevSeed(): Promise { const { tracer, logger } = resolveInstrumentation(); // Rule 0 const { queue } = resolveJobsDevSeed(); + // In-memory compliance bindings so the mounted consent/dsr routers work + // without Payload booted (A11). No audit sink in dev seed. + const { consentFactory } = bindDevSeedConsent(); + const dsrBinding = bindDevSeedDsr(); + complianceBindings = { consentFactory, dsrBinding }; + const ctx: BindContext = { tracer, logger, queue, + consentFactory, // Dev seed intentionally keeps the no-op limiter so local iteration and // seeded demos are never throttled; production binds InMemoryRateLimit. rateLimit: new NoopRateLimit(), @@ -172,15 +246,10 @@ export async function bindAllDevSeed(): Promise { export function bindAll(): Promise { if (bindPromise) return bindPromise; - if (process.env.USE_DEV_SEED === "false") { - bindPromise = bindAllProduction(); - } else if (process.env.USE_DEV_SEED === "true") { - bindPromise = bindAllDevSeed(); - } else if (process.env.NODE_ENV === "production") { - bindPromise = bindAllProduction(); - } else { - bindPromise = bindAllDevSeed(); - } + bindPromise = + resolveBindingMode() === "production" + ? bindAllProduction() + : bindAllDevSeed(); return bindPromise; } @@ -191,6 +260,7 @@ export function __resetBindStateForTests(): void { resolvedTracer = null; resolvedLogger = null; resolvedQueue = null; + complianceBindings = null; } /** Test-only accessor for resolved instrumentation. */ diff --git a/apps/web-next/src/server/trpc-context.test.ts b/apps/web-next/src/server/trpc-context.test.ts new file mode 100644 index 0000000..334dc83 --- /dev/null +++ b/apps/web-next/src/server/trpc-context.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findByID = vi.fn(); +vi.mock("payload", () => ({ + getPayload: vi.fn(async () => ({ findByID })), +})); +vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) })); + +const validateSession = vi.fn(); +vi.mock("@repo/auth/di/container", () => ({ + authContainer: { get: () => ({ validateSession }) }, +})); + +const consentFactory = vi.fn(async () => ({})); +const dsrBinding = { marker: "dsr-binding" }; +const resolveBindingMode = vi.fn<() => "production" | "dev-seed">( + () => "production", +); +vi.mock("./bind-production", () => ({ + bindAll: vi.fn(async () => {}), + getComplianceBindings: vi.fn(async () => ({ consentFactory, dsrBinding })), + resolveBindingMode: () => resolveBindingMode(), +})); + +import { createWebNextTrpcContext } from "./trpc-context"; + +function makeRequest(headers: Record = {}): Request { + return new Request("https://example.test/api/trpc", { headers }); +} + +describe("createWebNextTrpcContext (A11)", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveBindingMode.mockReturnValue("production"); + findByID.mockResolvedValue({ id: "user-1", role: "admin" }); + validateSession.mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("threads compliance bindings for anonymous requests", async () => { + const ctx = await createWebNextTrpcContext(makeRequest()); + expect(ctx.user).toBeUndefined(); + expect(ctx.userId).toBeUndefined(); + expect(ctx.consentFactory).toBe(consentFactory); + expect(ctx.dsrBinding).toBe(dsrBinding); + expect(validateSession).not.toHaveBeenCalled(); + }); + + it("derives clientIp from proxy headers (B2)", async () => { + const ctx = await createWebNextTrpcContext( + makeRequest({ "x-forwarded-for": "203.0.113.9" }), + ); + expect(ctx.clientIp).toBe("203.0.113.9"); + }); + + it("resolves the user + role snapshot from the payload-token cookie", async () => { + const ctx = await createWebNextTrpcContext( + makeRequest({ cookie: "payload-token=jwt-abc; other=1" }), + ); + expect(validateSession).toHaveBeenCalledWith("jwt-abc"); + expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] }); + expect(ctx.userId).toBe("user-1"); + }); + + it("resolves the dev-seed session cookie name too", async () => { + resolveBindingMode.mockReturnValue("dev-seed"); + const ctx = await createWebNextTrpcContext( + makeRequest({ cookie: "session=session_user-1" }), + ); + expect(validateSession).toHaveBeenCalledWith("session_user-1"); + // dev-seed has no Payload — role snapshot is empty + expect(ctx.user).toEqual({ id: "user-1", roles: [] }); + expect(findByID).not.toHaveBeenCalled(); + }); + + it("treats an invalid/expired session as anonymous", async () => { + validateSession.mockRejectedValue(new Error("Invalid or expired")); + const ctx = await createWebNextTrpcContext( + makeRequest({ cookie: "payload-token=tampered" }), + ); + expect(ctx.user).toBeUndefined(); + }); + + it("yields no roles when the users doc has none", async () => { + findByID.mockResolvedValue({ id: "user-1" }); + const ctx = await createWebNextTrpcContext( + makeRequest({ cookie: "payload-token=jwt-abc" }), + ); + expect(ctx.user).toEqual({ id: "user-1", roles: [] }); + }); +}); diff --git a/apps/web-next/src/server/trpc-context.ts b/apps/web-next/src/server/trpc-context.ts new file mode 100644 index 0000000..19b1a20 --- /dev/null +++ b/apps/web-next/src/server/trpc-context.ts @@ -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 { + 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 +>; diff --git a/packages/core-dsr/src/__tests__/dsr.router.test.ts b/packages/core-dsr/src/__tests__/dsr.router.test.ts index e1825b1..7eb137b 100644 --- a/packages/core-dsr/src/__tests__/dsr.router.test.ts +++ b/packages/core-dsr/src/__tests__/dsr.router.test.ts @@ -295,15 +295,40 @@ describe("dsrRouter subject scoping (A1 — IDOR)", () => { }); }); -describe("dsrRouter singleton guard", () => { - it("throws when procedures are called without a real DsrBinding", async () => { - // The singleton uses a Proxy that throws on any binding property access. - // Procedures access binding lazily, so the Proxy error surfaces at call time. +describe("dsrRouter singleton (context-time binding, A11)", () => { + it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", async () => { const caller = dsrRouter.createCaller({ user: authenticatedUser, } as Record); await expect( caller.export({ subjectId: "alice", format: "json" }), - ).rejects.toThrow(/dsrRouter singleton/); + ).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: expect.stringContaining("DsrBinding missing"), + }); + }); + + it("serves requests when the app provides ctx.dsrBinding", async () => { + const binding = makeBinding(); + const caller = dsrRouter.createCaller({ + user: authenticatedUser, + dsrBinding: binding, + } as Record); + const result = await caller.export({ subjectId: "alice", format: "json" }); + expect(result.subjectId).toBe("alice"); + expect(binding.dataExport.calls).toHaveLength(1); + }); + + it("prefers ctx.dsrBinding over the creation-time binding", async () => { + const creationBinding = makeBinding(); + const ctxBinding = makeBinding(); + const router = createDsrRouter(creationBinding as unknown as DsrBinding); + const caller = router.createCaller({ + user: authenticatedUser, + dsrBinding: ctxBinding, + } as Record); + await caller.export({ subjectId: "alice", format: "json" }); + expect(ctxBinding.dataExport.calls).toHaveLength(1); + expect(creationBinding.dataExport.calls).toHaveLength(0); }); }); diff --git a/packages/core-dsr/src/dsr.router.ts b/packages/core-dsr/src/dsr.router.ts index 635cc2c..915c128 100644 --- a/packages/core-dsr/src/dsr.router.ts +++ b/packages/core-dsr/src/dsr.router.ts @@ -51,21 +51,43 @@ function userFromCtx(ctx: object): DsrTrpcUser { return (ctx as { user: DsrTrpcUser }).user; } +/** tRPC context consumed by the DSR router (provided by the app's createContext). */ +export type DsrRouterContext = { + user?: DsrTrpcUser; + /** Per-request DSR binding — the app wires bindProductionDsr/bindDevSeedDsr output here. */ + dsrBinding?: DsrBinding; +}; + +function bindingFromCtx(ctx: object, fallback?: DsrBinding): DsrBinding { + const fromCtx = (ctx as DsrRouterContext).dsrBinding; + if (fromCtx) return fromCtx; + if (fallback) return fallback; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "DsrBinding missing — provide ctx.dsrBinding from createContext " + + "(bindProductionDsr/bindDevSeedDsr) or pass a binding to createDsrRouter", + }); +} + /** * Creates the DSR tRPC router. * - * Capture `binding` at router-creation time. Apps that mount this router - * must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`. + * The binding is resolved per request from `ctx.dsrBinding` (audit finding + * A11 — the mounted router must be live, not a dead stub), falling back to + * the optional `binding` captured at router-creation time. * * @example * ```ts + * // creation-time binding * const binding = bindProductionDsr({ config, auditLog }); * const appRouter = t.router({ ..., dsr: createDsrRouter(binding) }); + * + * // or context-time binding (what apps mounting the `dsrRouter` singleton do) + * createContext: () => ({ user, dsrBinding }) * ``` */ -export function createDsrRouter(binding: DsrBinding) { - // Handlers are created lazily (inside procedure closures) so that the - // dsrRouter singleton proxy doesn't trigger at module init time. +export function createDsrRouter(binding?: DsrBinding) { return t.router({ export: dsrProcedure .input( @@ -78,7 +100,8 @@ export function createDsrRouter(binding: DsrBinding) { ) .query(async ({ ctx, input }) => { assertSubjectScope(userFromCtx(ctx), input.subjectId); - const res = await createExportHandler(binding.dataExport)(input); + const b = bindingFromCtx(ctx, binding); + const res = await createExportHandler(b.dataExport)(input); return res.body; }), @@ -100,7 +123,8 @@ export function createDsrRouter(binding: DsrBinding) { message: "Admin role required for cascade-hard deletion", }); } - const res = await createDeleteHandler(binding.dataDelete)(input); + const b = bindingFromCtx(ctx, binding); + const res = await createDeleteHandler(b.dataDelete)(input); return res.body; }), @@ -117,8 +141,9 @@ export function createDsrRouter(binding: DsrBinding) { ) .mutation(async ({ ctx, input }) => { assertSubjectScope(userFromCtx(ctx), input.subjectId); + const b = bindingFromCtx(ctx, binding); // tRPC infers z.unknown() as value?: unknown; cast to assert presence - const res = await createRectifyHandler(binding.dataRectify)( + const res = await createRectifyHandler(b.dataRectify)( input as RectifyHandlerInput, ); return res.body; @@ -135,29 +160,19 @@ export function createDsrRouter(binding: DsrBinding) { ) .mutation(async ({ ctx, input }) => { assertSubjectScope(userFromCtx(ctx), input.subjectId); - const res = await createRestrictHandler(binding.processingRestriction)( - input, - ); + const b = bindingFromCtx(ctx, binding); + const res = await createRestrictHandler(b.processingRestriction)(input); return res.body; }), }); } /** - * Convenience singleton for projects with a single DSR binding instance. - * Most callers should use `createDsrRouter(binding)` and pass the binding - * explicitly. This export exists for type inference (`DsrRouter`) only. + * Router singleton mounted by the app router. It has no creation-time + * binding: every procedure resolves `ctx.dsrBinding`, which the app's + * `createContext` supplies per request (A11). Calls without a context + * binding fail with INTERNAL_SERVER_ERROR at request time. */ -export const dsrRouter = createDsrRouter( - new Proxy({} as DsrBinding, { - get(_target, prop) { - if (prop === "then") return undefined; // not a Promise - throw new Error( - `dsrRouter singleton used without providing a DsrBinding. ` + - `Use createDsrRouter(binding) instead.`, - ); - }, - }), -); +export const dsrRouter = createDsrRouter(); export type DsrRouter = ReturnType; diff --git a/packages/core-dsr/src/index.ts b/packages/core-dsr/src/index.ts index 15c5aa0..a61c2b5 100644 --- a/packages/core-dsr/src/index.ts +++ b/packages/core-dsr/src/index.ts @@ -37,7 +37,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production"; export { bindDevSeedDsr } from "./di/bind-dev-seed"; export { createDsrRouter, dsrRouter } from "./dsr.router"; -export type { DsrRouter, DsrTrpcUser } from "./dsr.router"; +export type { DsrRouter, DsrTrpcUser, DsrRouterContext } from "./dsr.router"; export type { HandlerResponse } from "./handlers/handler-types"; export { createExportHandler } from "./handlers/export-handler"; diff --git a/packages/core-shared/src/trpc/context.test.ts b/packages/core-shared/src/trpc/context.test.ts index 57f37e6..ff55ea0 100644 --- a/packages/core-shared/src/trpc/context.test.ts +++ b/packages/core-shared/src/trpc/context.test.ts @@ -46,4 +46,44 @@ describe("createTrpcContext", () => { clientIp: undefined, }); }); + + it("attaches the resolved user and mirrors userId (A11)", async () => { + const req = new Request("https://example.test/api/trpc"); + const ctx = await createTrpcContext(req, { + resolveUser: async () => ({ id: "user-1", roles: ["admin"] }), + }); + expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] }); + expect(ctx.userId).toBe("user-1"); + }); + + it("treats a null resolver result as anonymous", async () => { + const req = new Request("https://example.test/api/trpc"); + const ctx = await createTrpcContext(req, { + resolveUser: async () => null, + }); + expect(ctx.user).toBeUndefined(); + expect(ctx.userId).toBeUndefined(); + }); + + it("treats a throwing resolver as anonymous instead of failing", async () => { + const req = new Request("https://example.test/api/trpc"); + const ctx = await createTrpcContext(req, { + resolveUser: async () => { + throw new Error("expired session"); + }, + }); + expect(ctx.user).toBeUndefined(); + expect(ctx.clientIp).toBeUndefined(); + }); + + it("does not invoke the resolver without a request", async () => { + let called = false; + await createTrpcContext(undefined, { + resolveUser: async () => { + called = true; + return null; + }, + }); + expect(called).toBe(false); + }); }); diff --git a/packages/core-shared/src/trpc/context.ts b/packages/core-shared/src/trpc/context.ts index ff3a779..8f21cac 100644 --- a/packages/core-shared/src/trpc/context.ts +++ b/packages/core-shared/src/trpc/context.ts @@ -20,13 +20,49 @@ export function clientIpFromHeaders(headers: Headers): string | undefined { } /** - * Build the per-request tRPC context. Pass the adapter's incoming fetch - * `Request` so server-derived fields (currently `clientIp`) are attached — - * procedures must never trust client-supplied equivalents (B2). + * Server-resolved authenticated user attached to the tRPC context. + * Resolved from the app's session mechanism (never from client input); + * `roles` is a snapshot for role-gated procedures (admin checks). */ -export async function createTrpcContext(req?: Request) { +export type TrpcSessionUser = { + id: string; + roles: string[]; +}; + +export type CreateTrpcContextOpts = { + /** + * App-provided session resolver (audit finding A11). Receives the incoming + * request and returns the authenticated user, or null/undefined for + * anonymous callers. A throwing resolver is treated as anonymous — an + * expired or malformed session cookie must not 500 public queries; + * procedures that need a user reject with UNAUTHORIZED instead. + */ + resolveUser?: (req: Request) => Promise; +}; + +/** + * Build the per-request tRPC context. Pass the adapter's incoming fetch + * `Request` so server-derived fields (`clientIp`, and — when the app supplies + * a `resolveUser` — the authenticated `user`) are attached. Procedures must + * never trust client-supplied equivalents (B2). + */ +export async function createTrpcContext( + req?: Request, + opts: CreateTrpcContextOpts = {}, +) { + let user: TrpcSessionUser | undefined; + if (req && opts.resolveUser) { + try { + user = (await opts.resolveUser(req)) ?? undefined; + } catch { + user = undefined; + } + } return { clientIp: req ? clientIpFromHeaders(req.headers) : undefined, + user, + /** Convenience mirror of `user.id` (consumed by the consent router). */ + userId: user?.id, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b343f4..ccd8945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,9 +188,18 @@ importers: "@repo/core-api": specifier: workspace:* version: link:../../packages/core-api + "@repo/core-audit": + specifier: workspace:* + version: link:../../packages/core-audit "@repo/core-cms": specifier: workspace:* version: link:../../packages/core-cms + "@repo/core-consent": + specifier: workspace:* + version: link:../../packages/core-consent + "@repo/core-dsr": + specifier: workspace:* + version: link:../../packages/core-dsr "@repo/core-shared": specifier: workspace:* version: link:../../packages/core-shared