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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user