fix(compliance): port DSR/consent/audit/retention audit fixes

Ports the upstream compliance-core audit fixes onto the kept core-dsr,
core-consent, core-audit, core-cms and core-shared packages (pristine
template state here, so taken to the fixed end-state):

- core-dsr: scope DSR operations to the caller's own subject (A11);
  include the subject's audit trail in exports; resolve the per-request
  binding from ctx instead of a throwing singleton proxy.
- core-consent: build the consent router from the shared superjson
  transformer (A10); merge per-category on persist instead of replacing;
  validate migrated categories against an allow-list.
- core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the
  audit-logs collection and the req-scoped GDPR audit-erasure afterDelete
  hook (A6).
- core-shared: grace-purge soft-deleted rows via a retention-purge task +
  tombstone field and boot registration (A2/A3); add the
  require-authenticated tRPC helper; derive clientIp + resolve the session
  user in createTrpcContext (B2/A11).
- core-cms: register audit-logs, wire the audit-erasure hook and
  retention-purge tasks; adapted to our collection set (users, workspaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 23:54:14 +02:00
parent ac0bf80eca
commit 318a69e780
42 changed files with 2102 additions and 138 deletions

View File

@@ -16,6 +16,7 @@
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts",
"./trpc/require-authenticated": "./src/trpc/require-authenticated.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",

View File

@@ -25,6 +25,15 @@ export {
buildPurgeHandler,
registerRetentionPurgeJobs,
} from "./retention-purge/retention-purge.job";
export {
RETENTION_TOMBSTONE_FIELD,
hasPostDeletionPolicy,
withRetentionTombstone,
} from "./retention-purge/tombstone";
export {
buildRetentionPurgeTask,
type RetentionPurgeTask,
} from "./retention-purge/task";
export type {
PayloadPurgeApi,
GetPayloadFn,

View File

@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
});
});
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
describe("buildPurgeHandler — postDeletion grace purge", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
function postDeletionOnlyConfig(
action: "hard-delete" | "pseudonymize",
fields: MockCollection["fields"] = [],
) {
return makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
postDeletion: {
action,
duration: "P30D",
trigger: "after-deletion",
},
},
},
fields,
},
]);
}
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([]);
const deps: RetentionPurgeJobDeps = {
queue,
config: postDeletionOnlyConfig("hard-delete"),
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(payload.find).toHaveBeenCalledWith(
expect.objectContaining({
collection: "users",
where: {
deletedAt: {
less_than: new Date(
Date.now() - parseDurationMs("P30D"),
).toISOString(),
},
},
}),
);
});
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
{ id: "row-live" }, // never soft-deleted
]);
const deps: RetentionPurgeJobDeps = {
queue,
config: postDeletionOnlyConfig("hard-delete"),
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(payload.delete).toHaveBeenCalledTimes(1);
expect(payload.delete).toHaveBeenCalledWith({
collection: "users",
id: "row-old",
overrideAccess: true,
});
});
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
]);
const deps: RetentionPurgeJobDeps = {
queue,
config: postDeletionOnlyConfig("pseudonymize", [
{ name: "email", custom: { pii: { category: "contact-email" } } },
{ name: "status" },
]),
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(payload.update).toHaveBeenCalledWith({
collection: "users",
id: "row-old",
data: { email: null },
overrideAccess: true,
});
expect(payload.delete).not.toHaveBeenCalled();
});
it("records a retention-policy audit entry per purged row", async () => {
const { queue } = makeQueue();
const { auditLog, record } = makeAuditLog();
const payload = makePayloadApi([
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
]);
const deps: RetentionPurgeJobDeps = {
queue,
config: postDeletionOnlyConfig("hard-delete"),
getPayload: vi.fn().mockResolvedValue(payload),
auditLog,
};
await buildPurgeHandler("users", deps)();
expect(record).toHaveBeenCalledTimes(1);
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
action: "DELETE",
reason: "retention-policy",
resource: { type: "users", id: "row-old" },
}),
);
});
it("still runs activeRetention alongside postDeletion", async () => {
const { queue } = makeQueue();
const payload = makePayloadApi([
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
]);
const config = makeConfig([
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
activeRetention: { duration: "P2Y", trigger: "from-creation" },
postDeletion: {
action: "hard-delete",
duration: "P30D",
trigger: "after-deletion",
},
},
},
fields: [],
},
]);
const deps: RetentionPurgeJobDeps = {
queue,
config,
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
expect(payload.find).toHaveBeenCalledTimes(2);
// the fake returns the tombstoned row for both branches: the
// activeRetention branch deletes it by date, the postDeletion branch by
// tombstone — 2 delete calls for the same doc through different policies.
expect(payload.delete).toHaveBeenCalledTimes(2);
});
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
const { queue, enqueue } = makeQueue();
const payload = makePayloadApi([]);
const deps: RetentionPurgeJobDeps = {
queue,
config: postDeletionOnlyConfig("hard-delete"),
getPayload: vi.fn().mockResolvedValue(payload),
};
await buildPurgeHandler("users", deps)();
expect(enqueue).toHaveBeenCalledWith(
"retention-purge--users",
{},
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
);
});
});
// ---- buildPurgeHandler — pseudonymize branch ----
describe("buildPurgeHandler — pseudonymize", () => {

View File

@@ -1,6 +1,7 @@
import type { SanitizedConfig } from "payload";
import type { IJobQueue } from "../../jobs/job-queue.interface";
import type { AuditLogProtocol } from "../../di/bind-protocols";
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
/**
* Minimal Payload API surface needed by the retention purge job.
@@ -70,13 +71,19 @@ export function scheduleDelayMs(schedule: string): number {
return MS_PER_DAY; // "daily" and cron fallback
}
type RetentionActionName = "pseudonymize" | "hard-delete";
/**
* Build the purge handler for a single collection. The returned async function
* is intended to be registered as a Payload job task handler.
*
* Per run:
* 1. Query rows past their activeRetention period.
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
* 1. activeRetention (when declared): query rows past the active period
* (createdAt/updatedAt) and apply the retention action.
* 2. postDeletion (when declared, audit finding A2): query soft-deleted rows
* — tombstoned with `deletedAt` by the DSR soft-delete path — whose
* tombstone is older than postDeletion.duration, and apply
* postDeletion.action (pseudonymize | hard-delete).
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
* 4. Re-enqueue itself for the next purge cycle.
*
@@ -101,8 +108,64 @@ export function buildPurgeHandler(
);
}
// Re-bind after the guard: narrowing does not flow into hoisted closures.
const targetCollection = collection;
const taskSlug = `retention-purge--${collectionSlug}`;
async function applyAction(
payload: PayloadPurgeApi,
doc: Record<string, unknown>,
action: RetentionActionName,
reason: string,
): Promise<void> {
const id = doc["id"] as string | number;
if (action === "pseudonymize") {
const piiFields: Record<string, null> = {};
for (const field of targetCollection.fields) {
const f = field as { name?: string; custom?: { pii?: unknown } };
if (f.name && f.custom?.pii) {
piiFields[f.name] = null;
}
}
if (Object.keys(piiFields).length > 0) {
await payload.update({
collection: collectionSlug,
id,
data: piiFields,
overrideAccess: true,
});
}
} else {
await payload.delete({
collection: collectionSlug,
id,
overrideAccess: true,
});
}
if (auditLog) {
await auditLog.record({
actorId: "system",
actorType: "system",
actorRoles: [],
action: "DELETE",
resource: { type: collectionSlug, id: String(id) },
at: new Date(),
scope: {
feature: "core-shared",
environment: process.env["NODE_ENV"] ?? "production",
tenant: "default",
},
reason,
from: { ipTruncated: "system", userAgent: "background-job" },
containsPii: false,
outcome: "success",
});
}
}
return async () => {
const payload = await getPayload({ config });
const now = Date.now();
@@ -123,51 +186,37 @@ export function buildPurgeHandler(
const action = retention.postDeletion?.action ?? "hard-delete";
for (const doc of docs) {
const id = doc["id"] as string | number;
await applyAction(payload, doc, action, "retention-policy");
}
}
if (action === "pseudonymize") {
const piiFields: Record<string, null> = {};
for (const field of collection.fields) {
const f = field as { name?: string; custom?: { pii?: unknown } };
if (f.name && f.custom?.pii) {
piiFields[f.name] = null;
}
}
if (Object.keys(piiFields).length > 0) {
await payload.update({
collection: collectionSlug,
id,
data: piiFields,
overrideAccess: true,
});
}
} else {
await payload.delete({
collection: collectionSlug,
id,
overrideAccess: true,
});
}
if (retention.postDeletion) {
// Grace-period purge of soft-deleted rows (A2): the DSR soft delete
// stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
// declared action runs. Rows are re-checked client-side so a fake or
// permissive backend can never purge an un-tombstoned/unexpired row.
const { duration, action } = retention.postDeletion;
const cutoffMs = now - parseDurationMs(duration);
const cutoff = new Date(cutoffMs).toISOString();
if (auditLog) {
await auditLog.record({
actorId: "system",
actorType: "system",
actorRoles: [],
action: "DELETE",
resource: { type: collectionSlug, id: String(id) },
at: new Date(),
scope: {
feature: "core-shared",
environment: process.env["NODE_ENV"] ?? "production",
tenant: "default",
},
reason: "retention-policy",
from: { ipTruncated: "system", userAgent: "background-job" },
containsPii: false,
outcome: "success",
});
const { docs } = await payload.find({
collection: collectionSlug,
where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
limit: 1000,
overrideAccess: true,
});
const expired = docs.filter((doc) => {
const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
if (typeof tombstone !== "string" || tombstone.length === 0) {
return false;
}
const tombstoneMs = Date.parse(tombstone);
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
});
for (const doc of expired) {
await applyAction(payload, doc, action, "retention-policy");
}
}
@@ -189,7 +238,7 @@ export async function registerRetentionPurgeJobs(
const { queue, config } = deps;
const now = Date.now();
for (const collection of config.collections) {
for (const collection of config.collections ?? []) {
const retention = collection.custom?.retention;
if (!retention?.purgeSchedule) continue;

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import type { Payload } from "payload";
import { buildRetentionPurgeTask } from "@/payload/retention-purge/task";
function makeFakePayload() {
const find = vi.fn().mockResolvedValue({ docs: [] });
const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" });
const payload = {
config: {
collections: [
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
fields: [],
},
],
},
find,
update: vi.fn(),
delete: vi.fn(),
jobs: { queue: jobsQueue },
} as unknown as Payload;
return { payload, find, jobsQueue };
}
describe("buildRetentionPurgeTask (A3)", () => {
it("uses the retention-purge--<slug> task slug", () => {
expect(buildRetentionPurgeTask("users").slug).toBe(
"retention-purge--users",
);
});
it("runs the purge against req.payload and re-enqueues the next cycle", async () => {
const { payload, find, jobsQueue } = makeFakePayload();
const task = buildRetentionPurgeTask("users");
const result = await task.handler({ req: { payload } });
expect(result).toEqual({ output: {} });
// postDeletion branch queried the tombstone field
expect(find).toHaveBeenCalledWith(
expect.objectContaining({
collection: "users",
where: { deletedAt: expect.anything() },
}),
);
// self-re-enqueue went through the payload job queue
expect(jobsQueue).toHaveBeenCalledWith(
expect.objectContaining({ task: "retention-purge--users" }),
);
});
});

View File

@@ -0,0 +1,42 @@
import type { Payload } from "payload";
import { PayloadJobQueue } from "../../jobs/payload-job-queue";
import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job";
/**
* Minimal shape of a Payload job-task definition — enough for
* `payload.config.ts` `jobs.tasks` composition without dragging the full
* generated TaskConfig generics through core-shared.
*/
export type RetentionPurgeTask = {
slug: string;
handler: (args: { req: { payload: Payload } }) => Promise<{
output: Record<string, never>;
}>;
};
/**
* Build the Payload job-task definition for one collection's retention purge
* (audit finding A3): `registerRetentionPurgeJobs` enqueues
* `retention-purge--<slug>` tasks at boot, and this definition is what makes
* Payload able to RUN them. Everything the handler needs comes from the
* running instance on `req.payload` (config, local API, job queue for the
* self-re-enqueue), so the task can be declared at config-composition time
* with no bootstrapping order problems.
*/
export function buildRetentionPurgeTask(
collectionSlug: string,
): RetentionPurgeTask {
return {
slug: `retention-purge--${collectionSlug}`,
handler: async ({ req }) => {
const payload = req.payload;
const run = buildPurgeHandler(collectionSlug, {
queue: new PayloadJobQueue(payload),
config: payload.config,
getPayload: async () => payload as unknown as PayloadPurgeApi,
});
await run();
return { output: {} };
},
};
}

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import type { CollectionConfig } from "payload";
import {
RETENTION_TOMBSTONE_FIELD,
hasPostDeletionPolicy,
withRetentionTombstone,
} from "@/payload/retention-purge/tombstone";
function makeCollection(custom?: Record<string, unknown>): CollectionConfig {
return {
slug: "things",
custom,
fields: [{ name: "title", type: "text" }],
} as CollectionConfig;
}
const postDeletionRetention = {
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
};
describe("hasPostDeletionPolicy", () => {
it("is true when custom.retention.postDeletion is declared", () => {
expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe(
true,
);
});
it("is false without retention or postDeletion", () => {
expect(hasPostDeletionPolicy(makeCollection())).toBe(false);
expect(
hasPostDeletionPolicy(
makeCollection({ retention: { purgeSchedule: "daily" } }),
),
).toBe(false);
});
});
describe("withRetentionTombstone", () => {
it("appends the deletedAt field to postDeletion collections", () => {
const result = withRetentionTombstone(
makeCollection(postDeletionRetention),
);
const names = result.fields.map((f) => (f as { name?: string }).name);
expect(names).toContain(RETENTION_TOMBSTONE_FIELD);
const tombstone = result.fields.find(
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
) as { type?: string; index?: boolean };
expect(tombstone.type).toBe("date");
expect(tombstone.index).toBe(true);
});
it("returns collections without a postDeletion policy unchanged", () => {
const collection = makeCollection();
expect(withRetentionTombstone(collection)).toBe(collection);
});
it("does not duplicate an already-declared tombstone field", () => {
const collection = {
...makeCollection(postDeletionRetention),
fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }],
} as CollectionConfig;
const result = withRetentionTombstone(collection);
expect(
result.fields.filter(
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
),
).toHaveLength(1);
});
it("does not mutate the input collection", () => {
const collection = makeCollection(postDeletionRetention);
const before = collection.fields.length;
withRetentionTombstone(collection);
expect(collection.fields.length).toBe(before);
});
});

View File

@@ -0,0 +1,70 @@
import type { CollectionConfig, Field } from "payload";
import type { CollectionRetention } from "../retention-types";
/**
* Field name marking a row as soft-deleted.
*
* Written by the DSR soft-delete path (`PayloadDataDelete`, mode "soft") on
* rows the subject owns, and read by the retention purge job's
* `postDeletion` branch: rows whose tombstone is older than
* `postDeletion.duration` are purged with the declared action
* (hard-delete | pseudonymize).
*
* Deliberately distinct from `processingRestrictedAt` — an Art. 18
* processing restriction is NOT a deletion request and must never feed the
* grace-period purge.
*/
export const RETENTION_TOMBSTONE_FIELD = "deletedAt";
function collectionRetention(
collection: CollectionConfig,
): CollectionRetention | undefined {
return (collection.custom as { retention?: CollectionRetention } | undefined)
?.retention;
}
/** True when the collection declares a postDeletion grace-purge policy. */
export function hasPostDeletionPolicy(collection: {
custom?: Record<string, unknown>;
}): boolean {
return Boolean(
(collection.custom as { retention?: CollectionRetention } | undefined)
?.retention?.postDeletion,
);
}
/**
* Ensure a collection that declares `custom.retention.postDeletion` carries
* the soft-delete tombstone field so (a) the DSR soft delete can stamp it and
* (b) the purge job can query it. Collections without a postDeletion policy
* (or that already define the field) are returned unchanged.
*
* Apply at config-composition time (core-cms payload.config.ts).
*/
export function withRetentionTombstone(
collection: CollectionConfig,
): CollectionConfig {
if (!collectionRetention(collection)?.postDeletion) return collection;
const alreadyDeclared = collection.fields.some(
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
);
if (alreadyDeclared) return collection;
const tombstoneField: Field = {
name: RETENTION_TOMBSTONE_FIELD,
type: "date",
index: true,
admin: {
hidden: true,
description:
"Soft-delete tombstone (DSR Art. 17). Rows older than the " +
"postDeletion grace period are purged by the retention job.",
},
};
return {
...collection,
fields: [...collection.fields, tombstoneField],
};
}

View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { clientIpFromHeaders, createTrpcContext } from "@/trpc/context";
describe("clientIpFromHeaders", () => {
it("takes the first x-forwarded-for hop", () => {
const headers = new Headers({
"x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2",
});
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
});
it("trims whitespace around the first hop", () => {
const headers = new Headers({
"x-forwarded-for": " 203.0.113.7 , 10.0.0.1",
});
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
});
it("falls back to x-real-ip when x-forwarded-for is absent", () => {
const headers = new Headers({ "x-real-ip": "198.51.100.4" });
expect(clientIpFromHeaders(headers)).toBe("198.51.100.4");
});
it("returns undefined when neither header is present", () => {
expect(clientIpFromHeaders(new Headers())).toBeUndefined();
});
it("returns undefined for empty header values", () => {
const headers = new Headers({ "x-forwarded-for": " ", "x-real-ip": "" });
expect(clientIpFromHeaders(headers)).toBeUndefined();
});
});
describe("createTrpcContext", () => {
it("attaches the derived clientIp from the request", async () => {
const req = new Request("https://example.test/api/trpc", {
headers: { "x-forwarded-for": "203.0.113.7" },
});
await expect(createTrpcContext(req)).resolves.toEqual({
clientIp: "203.0.113.7",
});
});
it("yields an undefined clientIp without a request", async () => {
await expect(createTrpcContext()).resolves.toEqual({
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

@@ -1,5 +1,69 @@
export async function createTrpcContext() {
return {};
/**
* Derive the client IP from reverse-proxy headers.
*
* TRUST CAVEAT (audit finding B2): `x-forwarded-for` and `x-real-ip` are
* ordinary request headers. They are only trustworthy when the app runs
* behind a proxy/load balancer that overwrites (or verifiably appends to)
* them on every request. Exposed directly to the internet, a client can
* spoof them; deployments that need a hard guarantee must read the socket
* address at their edge and strip inbound copies of these headers.
*
* We take the FIRST `x-forwarded-for` entry — the client as reported by the
* first (trusted) hop — falling back to `x-real-ip`.
*/
export function clientIpFromHeaders(headers: Headers): string | undefined {
const forwarded = headers.get("x-forwarded-for");
const firstHop = forwarded?.split(",")[0]?.trim();
if (firstHop) return firstHop;
const realIp = headers.get("x-real-ip")?.trim();
return realIp || undefined;
}
/**
* 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 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,
};
}
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { t } from "@/trpc/init";
import {
requireAuthenticated,
protectedProcedure,
} from "@/trpc/require-authenticated";
const echoRouter = t.router({
publicEcho: t.procedure
.input(z.object({ value: z.string() }).strict())
.query(({ input }) => input.value),
protectedEcho: protectedProcedure
.input(z.object({ value: z.string() }).strict())
.mutation(({ input, ctx }) => ({
value: input.value,
userId: (ctx as { user: { id: string } }).user.id,
})),
composedEcho: t.procedure
.use(requireAuthenticated)
.input(z.object({}).strict())
.mutation(() => "ok"),
});
describe("requireAuthenticated middleware (B7)", () => {
it("rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = echoRouter.createCaller({});
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
await expect(caller.composedEcho({})).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("passes through authenticated callers and exposes ctx.user", async () => {
const caller = echoRouter.createCaller({
user: { id: "user-1", roles: [] },
});
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
value: "x",
userId: "user-1",
});
});
it("leaves public procedures untouched", async () => {
const caller = echoRouter.createCaller({});
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
});
});

View File

@@ -0,0 +1,32 @@
import { TRPCError } from "@trpc/server";
import { t } from "./init";
import type { TrpcSessionUser } from "./context";
/**
* Shared authentication guard for MUTATING tRPC procedures (audit finding
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
* via the app's `resolveUser`) and rejects anonymous callers with
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
* mutations in by composing this middleware into its procedure chain:
*
* ```ts
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
* ```
*
* The shared `t` is context-untyped, so the middleware narrows at runtime
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
* the downstream ctx with a non-optional type.
*/
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
const user = (ctx as { user?: TrpcSessionUser }).user;
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({ ctx: { ...ctx, user } });
});
/** Convenience base procedure for apps composing ad-hoc protected routes. */
export const protectedProcedure = t.procedure.use(requireAuthenticated);