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:
@@ -17,7 +17,10 @@
|
|||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
"@repo/blog": "workspace:*",
|
"@repo/blog": "workspace:*",
|
||||||
"@repo/core-api": "workspace:*",
|
"@repo/core-api": "workspace:*",
|
||||||
|
"@repo/core-audit": "workspace:*",
|
||||||
"@repo/core-cms": "workspace:*",
|
"@repo/core-cms": "workspace:*",
|
||||||
|
"@repo/core-consent": "workspace:*",
|
||||||
|
"@repo/core-dsr": "workspace:*",
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/core-trpc": "workspace:^",
|
"@repo/core-trpc": "workspace:^",
|
||||||
"@repo/marketing-pages": "workspace:*",
|
"@repo/marketing-pages": "workspace:*",
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
import { appRouter } from "@repo/core-api";
|
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) => {
|
const handler = async (req: Request) => {
|
||||||
return fetchRequestHandler({
|
return fetchRequestHandler({
|
||||||
endpoint: "/api/trpc",
|
endpoint: "/api/trpc",
|
||||||
req,
|
req,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
// Threads server-derived fields (clientIp from proxy headers — see the
|
// Real per-request context (A11): server-derived clientIp (B2, trust
|
||||||
// trust caveat in core-shared/trpc/context.ts) into every procedure (B2).
|
// caveat in core-shared/trpc/context.ts), the authenticated user resolved
|
||||||
createContext: () => createTrpcContext(req),
|
// from the session cookie (B7), and the consent/dsr bindings that make
|
||||||
|
// the mounted compliance routers live.
|
||||||
|
createContext: () => createWebNextTrpcContext(req),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||||
vi.mock("payload", () => ({
|
vi.mock("payload", () => ({
|
||||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ import {
|
|||||||
NoopRateLimit,
|
NoopRateLimit,
|
||||||
type RateLimitBudget,
|
type RateLimitBudget,
|
||||||
} from "@repo/core-shared/rate-limit";
|
} 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 { authManifest } from "@repo/auth";
|
||||||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||||||
import { bindProductionAuth } from "@repo/auth/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 resolvedLogger: ILogger | null = null;
|
||||||
let resolvedQueue: IJobQueue | 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<ComplianceBindings> {
|
||||||
|
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). */
|
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
|
||||||
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
|
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
|
||||||
if (resolvedTracer && resolvedLogger) {
|
if (resolvedTracer && resolvedLogger) {
|
||||||
@@ -108,11 +158,28 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
const { queue } = await resolveJobsProduction();
|
const { queue } = await resolveJobsProduction();
|
||||||
const resolvedConfig = await config;
|
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 = {
|
const ctx: BindProductionContext = {
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
queue,
|
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
|
// Real limiter in production (audit finding A4/B3): budgets come from the
|
||||||
// feature manifests, so manifest edits change enforcement without touching
|
// feature manifests, so manifest edits change enforcement without touching
|
||||||
// this file. In-memory ⇒ per-process counters; multi-instance deployments
|
// this file. In-memory ⇒ per-process counters; multi-instance deployments
|
||||||
@@ -136,10 +203,17 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||||
const { queue } = resolveJobsDevSeed();
|
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 = {
|
const ctx: BindContext = {
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
queue,
|
queue,
|
||||||
|
consentFactory,
|
||||||
// Dev seed intentionally keeps the no-op limiter so local iteration and
|
// Dev seed intentionally keeps the no-op limiter so local iteration and
|
||||||
// seeded demos are never throttled; production binds InMemoryRateLimit.
|
// seeded demos are never throttled; production binds InMemoryRateLimit.
|
||||||
rateLimit: new NoopRateLimit(),
|
rateLimit: new NoopRateLimit(),
|
||||||
@@ -172,15 +246,10 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
export function bindAll(): Promise<void> {
|
export function bindAll(): Promise<void> {
|
||||||
if (bindPromise) return bindPromise;
|
if (bindPromise) return bindPromise;
|
||||||
|
|
||||||
if (process.env.USE_DEV_SEED === "false") {
|
bindPromise =
|
||||||
bindPromise = bindAllProduction();
|
resolveBindingMode() === "production"
|
||||||
} else if (process.env.USE_DEV_SEED === "true") {
|
? bindAllProduction()
|
||||||
bindPromise = bindAllDevSeed();
|
: bindAllDevSeed();
|
||||||
} else if (process.env.NODE_ENV === "production") {
|
|
||||||
bindPromise = bindAllProduction();
|
|
||||||
} else {
|
|
||||||
bindPromise = bindAllDevSeed();
|
|
||||||
}
|
|
||||||
|
|
||||||
return bindPromise;
|
return bindPromise;
|
||||||
}
|
}
|
||||||
@@ -191,6 +260,7 @@ export function __resetBindStateForTests(): void {
|
|||||||
resolvedTracer = null;
|
resolvedTracer = null;
|
||||||
resolvedLogger = null;
|
resolvedLogger = null;
|
||||||
resolvedQueue = null;
|
resolvedQueue = null;
|
||||||
|
complianceBindings = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only accessor for resolved instrumentation. */
|
/** Test-only accessor for resolved instrumentation. */
|
||||||
|
|||||||
90
apps/web-next/src/server/trpc-context.test.ts
Normal file
90
apps/web-next/src/server/trpc-context.test.ts
Normal file
@@ -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<string, string> = {}): 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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
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>
|
||||||
|
>;
|
||||||
@@ -295,15 +295,40 @@ describe("dsrRouter subject scoping (A1 — IDOR)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dsrRouter singleton guard", () => {
|
describe("dsrRouter singleton (context-time binding, A11)", () => {
|
||||||
it("throws when procedures are called without a real DsrBinding", async () => {
|
it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", 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.
|
|
||||||
const caller = dsrRouter.createCaller({
|
const caller = dsrRouter.createCaller({
|
||||||
user: authenticatedUser,
|
user: authenticatedUser,
|
||||||
} as Record<string, unknown>);
|
} as Record<string, unknown>);
|
||||||
await expect(
|
await expect(
|
||||||
caller.export({ subjectId: "alice", format: "json" }),
|
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<string, unknown>);
|
||||||
|
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<string, unknown>);
|
||||||
|
await caller.export({ subjectId: "alice", format: "json" });
|
||||||
|
expect(ctxBinding.dataExport.calls).toHaveLength(1);
|
||||||
|
expect(creationBinding.dataExport.calls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,21 +51,43 @@ function userFromCtx(ctx: object): DsrTrpcUser {
|
|||||||
return (ctx as { user: DsrTrpcUser }).user;
|
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.
|
* Creates the DSR tRPC router.
|
||||||
*
|
*
|
||||||
* Capture `binding` at router-creation time. Apps that mount this router
|
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
|
||||||
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
|
* A11 — the mounted router must be live, not a dead stub), falling back to
|
||||||
|
* the optional `binding` captured at router-creation time.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
|
* // creation-time binding
|
||||||
* const binding = bindProductionDsr({ config, auditLog });
|
* const binding = bindProductionDsr({ config, auditLog });
|
||||||
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
* 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) {
|
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.
|
|
||||||
return t.router({
|
return t.router({
|
||||||
export: dsrProcedure
|
export: dsrProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -78,7 +100,8 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
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;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -100,7 +123,8 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
message: "Admin role required for cascade-hard deletion",
|
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;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -117,8 +141,9 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
|
const b = bindingFromCtx(ctx, binding);
|
||||||
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
// 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,
|
input as RectifyHandlerInput,
|
||||||
);
|
);
|
||||||
return res.body;
|
return res.body;
|
||||||
@@ -135,29 +160,19 @@ export function createDsrRouter(binding: DsrBinding) {
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||||
const res = await createRestrictHandler(binding.processingRestriction)(
|
const b = bindingFromCtx(ctx, binding);
|
||||||
input,
|
const res = await createRestrictHandler(b.processingRestriction)(input);
|
||||||
);
|
|
||||||
return res.body;
|
return res.body;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience singleton for projects with a single DSR binding instance.
|
* Router singleton mounted by the app router. It has no creation-time
|
||||||
* Most callers should use `createDsrRouter(binding)` and pass the binding
|
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
|
||||||
* explicitly. This export exists for type inference (`DsrRouter`) only.
|
* `createContext` supplies per request (A11). Calls without a context
|
||||||
|
* binding fail with INTERNAL_SERVER_ERROR at request time.
|
||||||
*/
|
*/
|
||||||
export const dsrRouter = createDsrRouter(
|
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 type DsrRouter = ReturnType<typeof createDsrRouter>;
|
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
|||||||
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
||||||
|
|
||||||
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
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 type { HandlerResponse } from "./handlers/handler-types";
|
||||||
export { createExportHandler } from "./handlers/export-handler";
|
export { createExportHandler } from "./handlers/export-handler";
|
||||||
|
|||||||
@@ -46,4 +46,44 @@ describe("createTrpcContext", () => {
|
|||||||
clientIp: undefined,
|
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
|
* Server-resolved authenticated user attached to the tRPC context.
|
||||||
* `Request` so server-derived fields (currently `clientIp`) are attached —
|
* Resolved from the app's session mechanism (never from client input);
|
||||||
* procedures must never trust client-supplied equivalents (B2).
|
* `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 {
|
return {
|
||||||
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
|
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
|
||||||
|
user,
|
||||||
|
/** Convenience mirror of `user.id` (consumed by the consent router). */
|
||||||
|
userId: user?.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -188,9 +188,18 @@ importers:
|
|||||||
"@repo/core-api":
|
"@repo/core-api":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-api
|
version: link:../../packages/core-api
|
||||||
|
"@repo/core-audit":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/core-audit
|
||||||
"@repo/core-cms":
|
"@repo/core-cms":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-cms
|
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":
|
"@repo/core-shared":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-shared
|
version: link:../../packages/core-shared
|
||||||
|
|||||||
Reference in New Issue
Block a user