diff --git a/apps/web-next/package.json b/apps/web-next/package.json index 2301877..33a4499 100644 --- a/apps/web-next/package.json +++ b/apps/web-next/package.json @@ -16,7 +16,10 @@ "dependencies": { "@repo/auth": "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:^", "@sentry/nextjs": "^10.51.0", 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 94971f1..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,12 +1,17 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@repo/core-api"; +import { createWebNextTrpcContext } from "../../../../server/trpc-context"; const handler = async (req: Request) => { return fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, - createContext: () => ({}), + // 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.rate-limit.test.ts b/apps/web-next/src/server/bind-production.rate-limit.test.ts new file mode 100644 index 0000000..a3f3bf2 --- /dev/null +++ b/apps/web-next/src/server/bind-production.rate-limit.test.ts @@ -0,0 +1,82 @@ +// A4/B3 regression: the PRODUCTION binder must enforce the auth manifest's +// rate-limit budgets. Unlike bind-production.test.ts (which mocks every +// feature binder), this file runs the REAL auth production binder against a +// stubbed Payload local API and drives sign-in through the app router. +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) })); + +const payloadStub = vi.hoisted(() => ({ + secret: "test-secret", + jobs: { queue: vi.fn() }, + // No user ever matches → every sign-in fails and consumes budget. + find: vi.fn(async () => ({ docs: [] })), + findByID: vi.fn(async () => null), + create: vi.fn(async ({ data }: { data: Record }) => data), +})); +vi.mock("payload", () => ({ getPayload: vi.fn(async () => payloadStub) })); + +describe("bindAllProduction rate limiting (A4/B3)", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("returns TOO_MANY_REQUESTS once the manifest ip budget is exhausted", async () => { + const { bindAllProduction } = await import("./bind-production"); + await bindAllProduction(); + + const { appRouter } = await import("@repo/core-api"); + const { authManifest } = await import("@repo/auth"); + const caller = appRouter.createCaller({ clientIp: "203.0.113.7" }); + + const attempt = () => + caller.auth.signIn({ username: "ghost", password: "wrong-password" }); + + // The manifest is the budget's source of truth: 5 failed attempts pass + // through (UNAUTHORIZED), the 6th trips the ip bucket. + const ipBudget = authManifest.useCases.signIn.rateLimit.find( + (b) => b.name === "ip", + ); + expect(ipBudget).toBeDefined(); + for (let i = 0; i < ipBudget!.budget; i++) { + await expect(attempt()).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + } + await expect(attempt()).rejects.toMatchObject({ + code: "TOO_MANY_REQUESTS", + }); + }); + + it("keeps other client IPs unaffected by an exhausted bucket", async () => { + const { bindAllProduction } = await import("./bind-production"); + await bindAllProduction(); + + const { appRouter } = await import("@repo/core-api"); + const { authManifest } = await import("@repo/auth"); + + const throttled = appRouter.createCaller({ clientIp: "198.51.100.9" }); + const ipBudget = authManifest.useCases.signIn.rateLimit.find( + (b) => b.name === "ip", + )!; + for (let i = 0; i < ipBudget.budget; i++) { + await expect( + throttled.auth.signIn({ + username: `user${i}x`, + password: "wrong-password", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + } + await expect( + throttled.auth.signIn({ username: "user0x", password: "wrong-password" }), + ).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + + // A different IP still gets an ordinary auth failure, not a throttle. + const fresh = appRouter.createCaller({ clientIp: "192.0.2.55" }); + await expect( + fresh.auth.signIn({ + username: "someoneelse", + password: "wrong-password", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index e0f1ee5..1b5dd5e 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() } })), @@ -59,6 +63,25 @@ describe("bindAllProduction", () => { expect(ctx.bus).toBeUndefined(); expect(ctx.queue).toBeInstanceOf(PayloadJobQueue); }); + + it("binds a real InMemoryRateLimit seeded from the auth manifest (A4)", async () => { + const { bindAllProduction } = await import("./bind-production"); + const { bindProductionAuth } = + await import("@repo/auth/di/bind-production"); + const { InMemoryRateLimit } = await import("@repo/core-shared/rate-limit"); + + await bindAllProduction(); + + const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0]; + expect(ctx.rateLimit).toBeInstanceOf(InMemoryRateLimit); + // The manifest budgets must be resolvable — an unknown budget throws. + await expect(ctx.rateLimit!.consume("ip", "smoke")).resolves.toMatchObject({ + allowed: true, + }); + await expect( + ctx.rateLimit!.consume("account", "smoke"), + ).resolves.toMatchObject({ allowed: true }); + }); }); describe("bindAllDevSeed", () => { @@ -78,6 +101,17 @@ describe("bindAllDevSeed", () => { expect(ctx.bus).toBeUndefined(); expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue); }); + + it("keeps the no-op rate limiter on the dev-seed path (never throttles locally)", async () => { + const { bindAllDevSeed } = await import("./bind-production"); + const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed"); + const { NoopRateLimit } = await import("@repo/core-shared/rate-limit"); + + await bindAllDevSeed(); + + const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0]; + expect(ctx.rateLimit).toBeInstanceOf(NoopRateLimit); + }); }); describe("bindAll dispatcher", () => { diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index b32a512..43fe81a 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -16,7 +16,28 @@ import { PayloadJobQueue, type IJobQueue, } from "@repo/core-shared/jobs"; -import { NoopRateLimit } from "@repo/core-shared/rate-limit"; +import { + InMemoryRateLimit, + NoopRateLimit, + type RateLimitBudget, +} from "@repo/core-shared/rate-limit"; +import { + registerRetentionPurgeJobs, + type GetPayloadFn, +} from "@repo/core-shared/payload"; +import { bindAudit } from "@repo/core-audit/di"; +import 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 { bindProductionAuth } from "@repo/auth/di/bind-production"; import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed"; @@ -31,6 +52,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) { @@ -73,6 +133,18 @@ function resolveJobsDevSeed(): { queue: IJobQueue } { return { queue }; } +/** + * Collect every per-use-case rate-limit budget declared in the feature + * manifests. Budgets are the manifests' source of truth (auth declares + * signIn ip/account budgets today); add further manifests here as features + * declare `rateLimit` entries. + */ +function collectManifestRateLimitBudgets(): RateLimitBudget[] { + return Object.values(authManifest.useCases).flatMap((useCase) => + "rateLimit" in useCase && useCase.rateLimit ? [...useCase.rateLimit] : [], + ); +} + /** * Production path: swap each feature's mock repository binding for the real * Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per @@ -83,15 +155,53 @@ 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, + sinks: ["payload", "stdout"], + }); + const { consentFactory } = bindProductionConsent({ + config: resolvedConfig, + auditLog, + }); + const dsrBinding = bindProductionDsr({ + config: resolvedConfig, + auditLog, + // cascade-hard deletions pseudonymize the subject's audit trail (A6) + auditErasure: auditLog, + }); + complianceBindings = { consentFactory, dsrBinding, auditLog }; + const ctx: BindProductionContext = { config: resolvedConfig, tracer, logger, queue, - rateLimit: new NoopRateLimit(), + 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 + // need a shared backend behind IRateLimit. + rateLimit: new InMemoryRateLimit(collectManifestRateLimitBudgets()), }; bindProductionAuth(ctx); + + // Kick off the retention purge cycle (audit finding A3): enqueue the first + // `retention-purge--` job for every collection declaring a + // custom.retention.purgeSchedule. The task definitions live in the Payload + // config (core-cms jobs.tasks); each run re-enqueues the next cycle. + await registerRetentionPurgeJobs({ + queue, + config: resolvedConfig, + getPayload: getPayload as unknown as GetPayloadFn, + auditLog, + }); } /** @@ -103,10 +213,25 @@ export async function bindAllDevSeed(): Promise { const { tracer, logger } = resolveInstrumentation(); // Rule 0 const { queue } = resolveJobsDevSeed(); + // Dev-seed audit trail: stdout JSON only (no Payload booted). Keeps the + // __audited brand path identical to production so the boot conformance + // assertion exercises the same wiring. + const { auditLog } = bindAudit(sharedContainer, { sinks: ["stdout"] }); + + // In-memory compliance bindings so the mounted consent/dsr routers work + // without Payload booted (A11). + const { consentFactory } = bindDevSeedConsent(); + const dsrBinding = bindDevSeedDsr(); + complianceBindings = { consentFactory, dsrBinding, auditLog }; + const ctx: BindContext = { tracer, logger, queue, + auditLog, + consentFactory, + // Dev seed intentionally keeps the no-op limiter so local iteration and + // seeded demos are never throttled; production binds InMemoryRateLimit. rateLimit: new NoopRateLimit(), }; @@ -133,15 +258,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; } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 77bca3c..8fc216e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,9 +182,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