feat(web-next): resolve session user + live compliance context and rate limits
Ports the upstream web-next compliance wiring onto the clean-slate auth-shell app, adapted to the auth-only collection set (no workspaces feature on this branch): - add a per-request createWebNextTrpcContext (A11): derives clientIp (B2), resolves the authenticated user from the session cookie via the auth feature's denylist-aware validateSession plus a role snapshot (B7), and threads the boot-time consent factory + DSR binding so the mounted consent/dsr routers are live instead of dead stubs. - bind the production/dev-seed consent + DSR compliance bindings in bind-production and expose them via getComplianceBindings; kick off the retention purge cycle (A3). - enforce manifest rate limits on the production path: bind InMemoryRateLimit seeded from the auth manifest's budgets (A4/B3) while dev-seed keeps the no-op limiter. Adds a regression test driving sign-in through the real production binder + app router. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
82
apps/web-next/src/server/bind-production.rate-limit.test.ts
Normal file
82
apps/web-next/src/server/bind-production.rate-limit.test.ts
Normal file
@@ -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<string, unknown> }) => 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" });
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<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). */
|
||||
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<void> {
|
||||
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--<slug>` 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<void> {
|
||||
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<void> {
|
||||
export function bindAll(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
>;
|
||||
Reference in New Issue
Block a user