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:
2026-07-10 17:55:05 +02:00
parent 8b78563881
commit 49241845b5
12 changed files with 453 additions and 48 deletions

View File

@@ -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);
});
});

View File

@@ -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<TrpcSessionUser | null | undefined>;
};
/**
* 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,
};
}