11 Commits

Author SHA1 Message Date
0003a276a5 Merge pull request 'chore/port-solidtime-audit-fixes' (#1) from chore/port-solidtime-audit-fixes into main
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
Reviewed-on: #1
2026-07-12 21:13:08 +00:00
e71b66908f fix(core-shared): satisfy strict typecheck in purge job + audit hook
Some checks failed
CI / typecheck + lint + boundaries + test + build (pull_request) Has been cancelled
CodeQL / Analyze (javascript-typescript) (pull_request) Has been cancelled
Sentry PII guard (R31) / pii-guard (pull_request) Has been cancelled
CI / Playwright e2e (pull_request) Has been cancelled
CI / Storybook smoke tests + visual regression (pull_request) Has been cancelled
The hoisted applyAction closure loses the collection narrowing (TS18048)
and apps with generated CollectionSlug unions reject comparing slugs to
'audit-logs' (TS2367). Re-bind the narrowed collection and widen the
slug comparison to string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
9f90f0513f feat(core-dsr): include the subject's audit trail in DSR exports
UserDataBundle advertised an auditLog field that the export never
populated (audit finding A14). PayloadDataExport now queries the
audit-logs collection scoped to actorId === subjectId and reconstructs
AuditEntry values from the flat rows; when the audit core's collection
is not registered the field stays undefined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
d95ae74aed fix(core-audit): keyed 128-bit pseudonyms + salted DSR certificate
pseudonymize() used an unkeyed sha256 over 'salt:id' truncated to 64
bits, and the DSR deletion certificate hashed the raw subjectId with no
salt at all (audit finding A13). Both now use HMAC-SHA256 keyed by
AUDIT_PSEUDONYM_SALT, truncated to 128 bits. Rotation semantics are
documented on pseudonymize(): a key rotation changes future pseudonyms
only — stored rows keep old tokens and erasure still matches by real
actorId — and the certificate change likewise affects new certificates
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
68a142fa6b fix(core-consent): validate migrated categories against an allow-list
The anonymous consent cookie is client-controlled, yet its categories
were granted verbatim at sign-up migration (audit finding A12; the
migration itself is already invoked in the auth sign-up use case and
bindAllProduction now threads a consentFactory so it runs in
production). Adds KNOWN_CONSENT_CATEGORIES + isKnownConsentCategory to
core-consent, filters in extractAnonymousConsent and
migrateAnonymousConsent, and mirrors the allow-list in the auth
sign-up cookie extractor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
08cf939e1f fix(core-consent): merge per-category on persist instead of replacing
PayloadConsent.persist() wrote the WHOLE consentState array from a
per-request cache, so two interleaved grant/withdraw requests dropped
one another's categories (audit finding A7). Persist now re-reads the
freshest stored state immediately before writing, overlays ONLY the
mutated categories, and adopts the merged view locally. The residual
same-window race is documented in the method doc — Payload json fields
have no targeted array patch, so read-merge-write is the trade-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
a2be5d5488 feat(core-cms): register audit-logs + wire GDPR audit erasure
The audit-logs collection was never registered (record() would throw),
bindAudit/createAuditErasureHook were unused, and DSR cascade-hard never
touched the audit trail (audit finding A6). core-cms now registers the
collection and wires a req-scoped afterDelete erasure hook on users;
bindAllProduction binds the audit log into consent/DSR; cascade-hard
pseudonymizes the subject's audit entries; the action select accepts
the full AuditAction enum so consent/DSR entries pass validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
7b0c2ea590 fix(auth): declare users email/username/displayName in DSR pii map
The DSR walkers read the COLLECTION-level custom.pii map, which the
users collection never declared — Art. 15 export returned bare ids and
Art. 17 soft delete redacted nothing; the auth-injected email field in
particular was invisible (audit finding A5). Declares email (auto-added
by Payload auth: true), username and displayName as exportable +
restrictable; walker tests pin a users-shaped collection end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
413ac0273c feat(core-shared): grace-purge soft-deleted rows + boot registration
The retention purge job gated its whole body on activeRetention while
every collection declares only postDeletion, and no app ever called
registerRetentionPurgeJobs — retention was dead end to end (audit
findings A2 + A3). The DSR soft delete now stamps a deletedAt tombstone
on postDeletion collections (kept distinct from processingRestrictedAt
so an Art. 18 restriction never feeds the purge), the job grace-purges
tombstoned rows past postDeletion.duration with the declared action,
core-cms injects the tombstone field + Payload task definitions, and
bindAllProduction enqueues the first purge cycle at boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
d09b3e2cdd feat(core-shared): auth-gate mutating feature procedures
Adds a shared requireAuthenticated tRPC middleware (reads the server-
resolved ctx.user from createTrpcContext) and applies it to every
mutating feature procedure — blog.createArticle and media.deleteMedia
were anonymous-callable (audit finding B7). Read-only queries stay
public; features compose <x>ProtectedProcedure from their error-mapped
base procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:25:46 +02:00
49241845b5 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>
2026-07-10 18:25:46 +02:00
54 changed files with 2321 additions and 148 deletions

View File

@@ -17,7 +17,10 @@
"@repo/auth": "workspace:*",
"@repo/blog": "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:^",
"@repo/marketing-pages": "workspace:*",

View File

@@ -1,15 +1,17 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
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) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
// Threads server-derived fields (clientIp from proxy headers — see the
// trust caveat in core-shared/trpc/context.ts) into every procedure (B2).
createContext: () => createTrpcContext(req),
// 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),
});
};

View File

@@ -1,8 +1,37 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
// 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";
// Hoisted so the payload mock and assertions share the same jobs.queue spy.
const { jobsQueueMock } = vi.hoisted(() => ({
jobsQueueMock: vi.fn(async () => ({ id: "job-1" })),
}));
vi.mock("@repo/core-cms", () => ({
default: Promise.resolve({
collections: [
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
fields: [],
},
{ slug: "pages", fields: [] },
],
}),
}));
vi.mock("payload", () => ({
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
getPayload: vi.fn(async () => ({ jobs: { queue: jobsQueueMock } })),
}));
vi.mock("@repo/blog/di/bind-production", () => ({
bindProductionBlog: vi.fn(),
@@ -69,6 +98,17 @@ describe("bindAllProduction", () => {
expect(bindProductionMedia).toHaveBeenCalledOnce();
});
it("registers retention purge jobs at production boot (A3)", async () => {
const { bindAllProduction } = await import("./bind-production");
await bindAllProduction();
// one enqueue per collection declaring custom.retention.purgeSchedule
expect(jobsQueueMock).toHaveBeenCalledTimes(1);
expect(jobsQueueMock).toHaveBeenCalledWith(
expect.objectContaining({ task: "retention-purge--users" }),
);
});
it("is idempotent via bindAll — second call does not re-bind", async () => {
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");

View File

@@ -21,6 +21,21 @@ import {
NoopRateLimit,
type RateLimitBudget,
} from "@repo/core-shared/rate-limit";
import {
registerRetentionPurgeJobs,
type GetPayloadFn,
} from "@repo/core-shared/payload";
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 { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
@@ -44,6 +59,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) {
@@ -108,11 +162,33 @@ 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,
});
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,
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
@@ -125,6 +201,17 @@ export async function bindAllProduction(): Promise<void> {
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(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,
});
}
/**
@@ -136,10 +223,17 @@ export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0
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 = {
tracer,
logger,
queue,
consentFactory,
// Dev seed intentionally keeps the no-op limiter so local iteration and
// seeded demos are never throttled; production binds InMemoryRateLimit.
rateLimit: new NoopRateLimit(),
@@ -172,15 +266,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;
}
@@ -191,6 +280,7 @@ export function __resetBindStateForTests(): void {
resolvedTracer = null;
resolvedLogger = null;
resolvedQueue = null;
complianceBindings = null;
}
/** Test-only accessor for resolved instrumentation. */

View 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: [] });
});
});

View 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>
>;

View File

@@ -131,6 +131,43 @@ describe("signUpUseCase", () => {
expect(result.clearCookie?.attributes.maxAge).toBe(0);
});
it("drops unknown categories from the client-controlled cookie (A12)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
await useCase({
username: "ivy",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=analytics,evil-made-up,__proto__; session=x",
});
expect(consent.grants.map((g) => g.category)).toEqual(["analytics"]);
});
it("does not migrate consent when every cookie category is unknown (A12)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "jack",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=hax,not-a-category",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when no cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);

View File

@@ -14,6 +14,16 @@ import type { IAuthenticationService } from "../services/authentication.service.
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
// Category allow-list (mirrors KNOWN_CONSENT_CATEGORIES in @repo/core-consent).
// The cookie is client-controlled: unknown strings are dropped, never granted
// (audit finding A12).
const KNOWN_CONSENT_CATEGORIES = [
"necessary",
"functional",
"analytics",
"marketing",
];
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
for (const part of cookieHeader.split(";")) {
const eqIdx = part.indexOf("=");
@@ -24,7 +34,8 @@ function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
const cats = value
.split(",")
.map((c) => c.trim())
.filter(Boolean);
.filter(Boolean)
.filter((c) => KNOWN_CONSENT_CATEGORIES.includes(c));
return cats.length > 0 ? cats : null;
}
return null;

View File

@@ -16,6 +16,31 @@ export const users: CollectionConfig = {
},
},
subject: { kind: "self", field: "id" },
// Collection-level PII map consumed by the DSR walkers (audit finding
// A5): export includes fields marked exportable; the soft-delete path
// redacts them. `email` is auto-added by Payload's `auth: true` and has
// no explicit field entry below, so it MUST be declared here or Art. 15
// export misses it and Art. 17 soft delete leaves it behind.
pii: {
email: {
category: "contact-email",
purpose: ["account-authentication", "transactional-notifications"],
exportable: true,
restrictable: true,
},
username: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
displayName: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
fields: [
{

View File

@@ -1,5 +1,6 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";
@@ -10,3 +11,10 @@ export const blogProcedure = t.procedure.use(
[ArticleNotFoundError, "NOT_FOUND"],
]),
);
/**
* Base procedure for MUTATING blog routes (audit finding B7): anonymous
* callers are rejected with UNAUTHORIZED before the controller runs.
* Read-only queries stay on `blogProcedure`.
*/
export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);

View File

@@ -30,7 +30,10 @@ describe("blogRouter", () => {
});
it("createArticle then articleBySlug returns the article", async () => {
const caller = blogRouter.createCaller({});
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
const caller = blogRouter.createCaller({
user: { id: "u1", roles: [] },
});
const created = await caller.createArticle({
title: "Router Test Article",
@@ -45,6 +48,34 @@ describe("blogRouter", () => {
});
});
describe("blogRouter authorization (B7)", () => {
beforeEach(() => {
blogContainer.unbindAll();
blogContainer.load(BlogModule);
});
afterEach(() => {
blogContainer.unbindAll();
});
it("createArticle rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = blogRouter.createCaller({});
await expect(
caller.createArticle({
title: "Nope",
content: null,
authorId: "u1",
slug: "nope",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("read-only queries stay public", async () => {
const caller = blogRouter.createCaller({});
await expect(caller.listArticles({})).resolves.toEqual([]);
});
});
describe("blogRouter error mapping", () => {
beforeEach(() => {
blogContainer.unbindAll();

View File

@@ -11,7 +11,7 @@ import type { IGetArticlesController } from "../../interface-adapters/controller
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
import { blogProcedure } from "./procedures";
import { blogProcedure, blogProtectedProcedure } from "./procedures";
export const blogRouter = router({
articleBySlug: blogProcedure
@@ -32,7 +32,8 @@ export const blogRouter = router({
return ctrl(input);
}),
createArticle: blogProcedure
// Mutations require an authenticated caller (B7).
createArticle: blogProtectedProcedure
.input(createArticleInputSchema)
.mutation(({ input }) => {
const ctrl = blogContainer.get<ICreateArticleController>(

View File

@@ -2,17 +2,42 @@ import { describe, it, expect } from "vitest";
import { auditLogsCollection } from "./audit-logs-collection";
describe("auditLogsCollection", () => {
it("accepts every AuditAction enum value (A6)", () => {
const action = (
auditLogsCollection.fields as Array<{ name: string; options?: string[] }>
).find((f) => f.name === "action");
expect(action?.options).toEqual(
expect.arrayContaining([
"VIEW",
"CREATE",
"UPDATE",
"DELETE",
"EXPORT",
"PERMISSION_CHANGE",
"CONSENT_GRANT",
"CONSENT_WITHDRAW",
"RESTRICT",
"UNRESTRICT",
]),
);
});
it("uses slug 'audit-logs'", () => {
expect(auditLogsCollection.slug).toBe("audit-logs");
});
it("is append-only (update: () => false)", () => {
const access = auditLogsCollection.access as Record<string, (() => boolean) | undefined>;
const access = auditLogsCollection.access as Record<
string,
(() => boolean) | undefined
>;
expect(access["update"]?.()).toBe(false);
});
it("has the required fields", () => {
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
const fieldNames = (
auditLogsCollection.fields as Array<{ name: string }>
).map((f) => f.name);
// WHO
expect(fieldNames).toContain("actorId");
expect(fieldNames).toContain("actorType");

View File

@@ -44,7 +44,21 @@ export const auditLogsCollection: CollectionConfig = {
{
name: "action",
type: "select",
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
// Mirrors the AuditAction enum in @repo/core-shared/audit — the DSR and
// consent cores record RESTRICT/UNRESTRICT/CONSENT_* entries, so the
// select must accept every enum value or record() fails validation (A6).
options: [
"VIEW",
"CREATE",
"UPDATE",
"DELETE",
"EXPORT",
"PERMISSION_CHANGE",
"CONSENT_GRANT",
"CONSENT_WITHDRAW",
"RESTRICT",
"UNRESTRICT",
],
required: true,
index: true,
},

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { createAuditErasureHook } from "./audit-erasure-hook";
import {
createAuditErasureHook,
createReqScopedAuditErasureHook,
} from "./audit-erasure-hook";
import type { IAuditLog } from "../audit-log.interface";
function makeAuditLog(): IAuditLog {
@@ -25,7 +28,10 @@ describe("createAuditErasureHook", () => {
const auditLog = makeAuditLog();
const hook = createAuditErasureHook({ auditLog });
await hook(hookArgs("user_1") as never);
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
expect(auditLog.eraseSubject).toHaveBeenCalledWith(
"user_1",
"pseudonymize",
);
});
it("respects explicit mode='delete'", async () => {
@@ -63,3 +69,78 @@ describe("createAuditErasureHook", () => {
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
});
});
describe("createReqScopedAuditErasureHook (A6)", () => {
function makeReqPayload(withAuditCollection: boolean) {
const find = vi.fn().mockResolvedValue({ docs: [{ id: "log-1" }] });
const update = vi.fn().mockResolvedValue({});
const del = vi.fn().mockResolvedValue({});
const payload = {
config: {
collections: withAuditCollection ? [{ slug: "audit-logs" }] : [],
},
find,
update,
delete: del,
};
return { payload, find, update, del };
}
function reqHookArgs(id: unknown, payload: unknown) {
return {
doc: { id },
req: { payload } as never,
id: String(id),
collection: {} as never,
context: {},
};
}
it("pseudonymizes the deleted subject's audit entries via req.payload", async () => {
const { payload, find, update } = makeReqPayload(true);
const hook = createReqScopedAuditErasureHook();
await hook(reqHookArgs("user_1", payload) as never);
expect(find).toHaveBeenCalledWith(
expect.objectContaining({
collection: "audit-logs",
where: { actorId: { equals: "user_1" } },
}),
);
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
collection: "audit-logs",
id: "log-1",
data: { actorId: expect.stringMatching(/^erased-/) },
}),
);
});
it("respects mode='delete'", async () => {
const { payload, del } = makeReqPayload(true);
const hook = createReqScopedAuditErasureHook({ mode: "delete" });
await hook(reqHookArgs("user_2", payload) as never);
expect(del).toHaveBeenCalledWith(
expect.objectContaining({
collection: "audit-logs",
where: { actorId: { equals: "user_2" } },
}),
);
});
it("no-ops when the audit-logs collection is not registered", async () => {
const { payload, find, update, del } = makeReqPayload(false);
const hook = createReqScopedAuditErasureHook();
await hook(reqHookArgs("user_1", payload) as never);
expect(find).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(del).not.toHaveBeenCalled();
});
it("skips invalid doc ids", async () => {
const { payload, find } = makeReqPayload(true);
const hook = createReqScopedAuditErasureHook();
await hook(reqHookArgs(undefined, payload) as never);
expect(find).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import type { CollectionAfterDeleteHook } from "payload";
import type { IAuditLog } from "../audit-log.interface";
import { PayloadAuditLog } from "../payload-audit-log";
export type AuditErasureHookOpts = {
/** The audit log impl that will perform the erasure. */
@@ -36,3 +37,37 @@ export function createAuditErasureHook(
}
};
}
export type ReqScopedAuditErasureHookOpts = {
/** Erasure mode — see AuditErasureHookOpts. Defaults to "pseudonymize". */
mode?: "pseudonymize" | "delete";
};
/**
* Variant of `createAuditErasureHook` for config-composition time (audit
* finding A6): a Payload collection config is built before any `IAuditLog`
* can exist (binding the audit log needs the built config), so this hook
* constructs a `PayloadAuditLog` lazily from the running instance on
* `req.payload` when the delete fires. No-ops when the `audit-logs`
* collection is not registered.
*/
export function createReqScopedAuditErasureHook(
opts: ReqScopedAuditErasureHookOpts = {},
): CollectionAfterDeleteHook {
const mode = opts.mode ?? "pseudonymize";
return async ({ doc, req }) => {
if (typeof doc.id !== "string" && typeof doc.id !== "number") return;
const payload = req.payload;
// `slug as string`: apps with generated CollectionSlug types narrow slug
// to their registered union, which need not include "audit-logs".
const hasAuditCollection = payload.config.collections?.some(
(c) => (c.slug as string) === "audit-logs",
);
if (!hasAuditCollection) return;
const auditLog = new PayloadAuditLog(
payload.config,
async () => payload as never,
);
await auditLog.eraseSubject(String(doc.id), mode);
};
}

View File

@@ -1,6 +1,8 @@
export {
createAuditErasureHook,
createReqScopedAuditErasureHook,
type AuditErasureHookOpts,
type ReqScopedAuditErasureHookOpts,
} from "./audit-erasure-hook";
export {
createAuditAfterReadHook,

View File

@@ -16,7 +16,9 @@ export { AUDIT_SYMBOLS } from "./di/symbols";
export { pseudonymize } from "./pseudonymize";
export {
createAuditErasureHook,
createReqScopedAuditErasureHook,
type AuditErasureHookOpts,
type ReqScopedAuditErasureHookOpts,
} from "./hooks/audit-erasure-hook";
// VIEW capture
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";

View File

@@ -25,7 +25,10 @@ describe("PayloadAuditLog.record", () => {
await log.record(sample);
expect(mockCreate).toHaveBeenCalledOnce();
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
const call = mockCreate.mock.calls[0]![0] as {
collection: string;
data: Record<string, unknown>;
};
expect(call.collection).toBe("audit-logs");
expect(call.data.actorId).toBe("user_1");
expect(call.data.action).toBe("UPDATE");
@@ -101,14 +104,21 @@ describe("PayloadAuditLog.eraseSubject", () => {
// update called for each doc
expect(mockUpdate).toHaveBeenCalledTimes(2);
const updateCalls = mockUpdate.mock.calls as Array<
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
[
{
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: boolean;
},
]
>;
expect(updateCalls[0]![0].id).toBe("doc_a");
expect(updateCalls[1]![0].id).toBe("doc_b");
// both updates replace actorId with the same pseudonym
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
expect(pseudonym).toMatch(/^erased-[0-9a-f]{32}$/);
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
// overrideAccess bypasses the append-only rule
@@ -118,7 +128,9 @@ describe("PayloadAuditLog.eraseSubject", () => {
it("mode='pseudonymize' with no matching docs does not call update", async () => {
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
const mockUpdate = vi.fn();
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
const mockGetPayload = vi
.fn()
.mockResolvedValue({ find: mockFind, update: mockUpdate });
const log = new PayloadAuditLog({} as never, mockGetPayload);
await log.eraseSubject("unknown_user", "pseudonymize");

View File

@@ -21,13 +21,28 @@ describe("pseudonymize", () => {
expect(result).toMatch(/^erased-/);
});
it("produces exactly 16 hex chars after the prefix", () => {
it("produces exactly 32 hex chars (128 bits) after the prefix (A13)", () => {
const result = pseudonymize("user_42");
const hex = result.slice("erased-".length);
expect(hex).toHaveLength(16);
expect(hex).toHaveLength(32);
expect(hex).toMatch(/^[0-9a-f]+$/);
});
it("matches HMAC-SHA256(key, actorId) - keyed, not a bare hash (A13)", async () => {
const { createHmac, createHash } = await import("node:crypto");
const expected = createHmac("sha256", "test-salt-1")
.update("user_42")
.digest("hex")
.slice(0, 32);
expect(pseudonymize("user_42")).toBe(`erased-` + expected);
// and it must NOT be the legacy unkeyed sha256("salt:id") scheme
const legacy = createHash("sha256")
.update("test-salt-1:user_42")
.digest("hex")
.slice(0, 32);
expect(pseudonymize("user_42")).not.toBe(`erased-` + legacy);
});
it("is deterministic — same salt + actorId always yields the same token", () => {
const a = pseudonymize("user_42");
const b = pseudonymize("user_42");
@@ -53,6 +68,6 @@ describe("pseudonymize", () => {
delete process.env["AUDIT_PSEUDONYM_SALT"];
// Should not throw; just use the fallback.
const result = pseudonymize("user_1");
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
expect(result).toMatch(/^erased-[0-9a-f]{32}$/);
});
});

View File

@@ -1,22 +1,34 @@
import { createHash } from "node:crypto";
import { createHmac } from "node:crypto";
/**
* Produces a stable, irreversible token for a GDPR-erased actorId.
*
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
* Format: `erased-<first-32-hex-chars-of-HMAC-SHA256(key, actorId)>`
* 128 bits of a KEYED digest (audit finding A13). The previous scheme was an
* unkeyed `sha256("salt:actorId")` truncated to 64 bits, which invited both
* brute-force reversal of small id spaces and birthday collisions.
*
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
* The HMAC key is read from `AUDIT_PSEUDONYM_SALT` at call time so that
* production binding can pre-validate the var at boot (see `bindAudit`)
* while tests can override it per-test via `process.env`.
*
* Fallback salt is intentionally weak and labelled so that any token
* Rotation expectations (documented, by design):
* - Rotating the key changes pseudonyms produced FROM THEN ON only. Audit
* rows already pseudonymized keep tokens derived from the previous key;
* nothing re-keys stored rows, so a subject's pre- and post-rotation
* tokens no longer correlate. That linkage break is acceptable — the
* token's only job is severing PII linkage, not long-term correlation.
* - Re-erasing a subject after rotation still works: erasure matches rows
* by the REAL actorId, not by a previous pseudonym.
* - Rotate by replacing the env value (e.g. `openssl rand -hex 32`); keep
* retired keys only if you have an explicit need to re-correlate old rows.
*
* Fallback key is intentionally weak and labelled so that any token
* produced with it is recognisable as a dev/test artefact.
*/
export function pseudonymize(actorId: string): string {
const salt =
const key =
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
const hash = createHash("sha256")
.update(`${salt}:${actorId}`)
.digest("hex");
return `erased-${hash.slice(0, 16)}`;
const digest = createHmac("sha256", key).update(actorId).digest("hex");
return `erased-${digest.slice(0, 32)}`;
}

View File

@@ -18,6 +18,8 @@
"@payloadcms/richtext-lexical": "^3.14.0",
"@repo/auth": "workspace:*",
"@repo/blog": "workspace:*",
"@repo/core-audit": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
"@repo/navigation": "workspace:*",

View File

@@ -10,11 +10,46 @@ describe("payloadConfig composition", () => {
);
});
it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => {
const resolved = await config;
for (const slug of ["users", "articles", "media"]) {
const collection = resolved.collections?.find((c) => c.slug === slug);
const names =
collection?.fields.map((f) => (f as { name?: string }).name) ?? [];
expect(names, `collection ${slug}`).toContain("deletedAt");
}
});
it("registers a retention purge task per purgeSchedule collection (A3)", async () => {
const resolved = await config;
const taskSlugs =
(
resolved.jobs as { tasks?: Array<{ slug: string }> } | undefined
)?.tasks?.map((t) => t.slug) ?? [];
expect(taskSlugs).toEqual(
expect.arrayContaining([
"retention-purge--users",
"retention-purge--articles",
"retention-purge--media",
]),
);
});
it("registers the audit-logs collection (A6)", async () => {
const resolved = await config;
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
expect(slugs).toContain("audit-logs");
});
it("wires the audit erasure afterDelete hook on users (A6)", async () => {
const resolved = await config;
const users = resolved.collections?.find((c) => c.slug === "users");
expect(users?.hooks?.afterDelete?.length ?? 0).toBeGreaterThan(0);
});
it("registers all feature globals", async () => {
const resolved = await config;
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
expect(slugs).toEqual(
expect.arrayContaining(["site-settings", "header"]),
);
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
});
});

View File

@@ -4,7 +4,15 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { users } from "@repo/auth/cms";
import {
withRetentionTombstone,
buildRetentionPurgeTask,
} from "@repo/core-shared/payload";
import {
auditLogsCollection,
createReqScopedAuditErasureHook,
} from "@repo/core-audit";
import { users as usersBase } from "@repo/auth/cms";
import { articles } from "@repo/blog/cms";
import { media } from "@repo/media/cms";
import { pages, siteSettings } from "@repo/marketing-pages/cms";
@@ -13,9 +21,32 @@ import { header } from "@repo/navigation/cms";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
// GDPR audit erasure (audit finding A6): when a users row is hard-deleted
// (admin expunge, DSR cascade-hard, retention purge), pseudonymize that
// subject's audit-log entries so the trail keeps its shape without PII linkage.
const users = {
...usersBase,
hooks: {
...usersBase.hooks,
afterDelete: [
...(usersBase.hooks?.afterDelete ?? []),
createReqScopedAuditErasureHook(),
],
},
};
// Collections declaring custom.retention.postDeletion get the soft-delete
// tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the
// retention purge job can grace-purge them (audit finding A2).
const collections = [
...[users, articles, pages, media].map(withRetentionTombstone),
// Local audit sink (A6) — required for PayloadAuditLog.record() to work.
auditLogsCollection,
];
export default buildConfig({
editor: lexicalEditor(),
collections: [users, articles, pages, media],
collections,
globals: [siteSettings, header],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({
@@ -25,6 +56,14 @@ export default buildConfig({
"postgresql://postgres:postgres@localhost:5433/template",
},
}),
jobs: {
// Task definitions for the retention purge (audit finding A3):
// registerRetentionPurgeJobs (called from bindAllProduction) enqueues
// `retention-purge--<slug>` jobs; these definitions let Payload run them.
tasks: collections
.filter((c) => Boolean(c.custom?.retention?.purgeSchedule))
.map((c) => buildRetentionPurgeTask(c.slug)) as never,
},
typescript: {
outputFile: path.resolve(dirname, "generated-types.ts"),
},

View File

@@ -10,6 +10,27 @@ export type ConsentCategory =
| "marketing"
| (string & {});
/**
* The known consent categories (audit finding A12). Untrusted inputs — e.g.
* the anonymous banner cookie migrated at sign-up — MUST be validated against
* this list before being granted; the open ConsentCategory union is for
* first-party code registering custom categories deliberately, not for
* client-controlled strings.
*/
export const KNOWN_CONSENT_CATEGORIES = [
"necessary",
"functional",
"analytics",
"marketing",
] as const;
/** Type guard for the allow-list above. */
export function isKnownConsentCategory(
value: string,
): value is (typeof KNOWN_CONSENT_CATEGORIES)[number] {
return (KNOWN_CONSENT_CATEGORIES as readonly string[]).includes(value);
}
/** Whether a subject has granted or denied consent for a category. */
export type ConsentState = "granted" | "denied" | "pending";

View File

@@ -4,6 +4,10 @@ export type {
UserConsentState,
ConsentGrantMeta,
} from "./consent-types";
export {
KNOWN_CONSENT_CATEGORIES,
isKnownConsentCategory,
} from "./consent-types";
export type { IConsent } from "./consent.interface";
export type { ConsentChecked } from "./with-consent";
export { withConsent } from "./with-consent";

View File

@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
});
});
describe("extractAnonymousConsent — category allow-list (A12)", () => {
it("drops unknown categories from the client-controlled cookie", () => {
const result = extractAnonymousConsent(
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
);
expect(result).toEqual(["necessary", "analytics"]);
});
it("returns null when every category is unknown", () => {
expect(
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
).toBeNull();
});
});
describe("migrateAnonymousConsent", () => {
it("calls IConsent.grant with method signup-migration for each category", async () => {
const consent = new RecordingConsent();
@@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => {
expect(consent.isGranted("marketing")).toBe(true);
});
});
describe("migrateAnonymousConsent — category allow-list (A12)", () => {
it("never grants unknown categories even when passed directly", async () => {
const consent = new RecordingConsent();
await migrateAnonymousConsent({
consent,
cookieState: ["analytics", "totally-made-up", "marketing"],
});
expect(consent.grants.map((g) => g.category)).toEqual([
"analytics",
"marketing",
]);
});
});

View File

@@ -1,4 +1,8 @@
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types";
import {
isKnownConsentCategory,
type ConsentCategory,
type ConsentGrantMeta,
} from "./consent-types";
import type { IConsent } from "./consent.interface";
/** Cookie name written by the anonymous consent banner. */
@@ -11,6 +15,10 @@ export const CONSENT_COOKIE_NAME = "cc_consent";
*
* Expected cookie value format: comma-separated category names,
* e.g. "necessary,analytics,marketing".
*
* The cookie is client-controlled, so values are validated against
* KNOWN_CONSENT_CATEGORIES (audit finding A12) — unknown strings are
* dropped rather than granted.
*/
export function extractAnonymousConsent(
cookieHeader: string,
@@ -21,7 +29,8 @@ export function extractAnonymousConsent(
const categories = raw
.split(",")
.map((c) => c.trim())
.filter(Boolean) as ConsentCategory[];
.filter(Boolean)
.filter(isKnownConsentCategory) as ConsentCategory[];
return categories.length > 0 ? categories : null;
}
@@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: {
const meta: ConsentGrantMeta = { method: "signup-migration" };
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
if (policyVersion !== undefined) meta.policyVersion = policyVersion;
for (const category of cookieState) {
// Defense in depth (A12): even a caller that bypassed
// extractAnonymousConsent cannot grant unknown categories.
for (const category of cookieState.filter(isKnownConsentCategory)) {
await consent.grant(category, meta);
}
}

View File

@@ -259,3 +259,106 @@ describe("PayloadConsent.load — deserializeEntry branches", () => {
expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date);
});
});
describe("PayloadConsent.persist — read-merge-write (A7)", () => {
async function makeTwoConsents() {
const mock = makePayloadMock();
const a = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
const b = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
// Both instances hydrate from the SAME empty snapshot — the per-request
// cache staleness that caused the lost update.
await a.load();
await b.load();
return { a, b, ...mock };
}
function storedCategories(db: Record<string, unknown[]>): string[] {
return (db["user_1"] ?? [])
.map((e) => (e as { category: string }).category)
.sort();
}
it("two interleaved grants from stale caches both survive", async () => {
const { a, b, db } = await makeTwoConsents();
await a.grant("analytics");
await b.grant("marketing"); // pre-fix: whole-array write dropped "analytics"
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
});
it("a grant and a withdraw on different categories both survive", async () => {
const { a, b, db } = await makeTwoConsents();
await a.grant("analytics");
await b.grant("marketing");
await a.withdraw("analytics");
expect(storedCategories(db)).toEqual(["analytics", "marketing"]);
const analytics = (
db["user_1"] as Array<{ category: string; state: string }>
).find((e) => e.category === "analytics");
expect(analytics?.state).toBe("denied");
});
it("adopts concurrent writers' entries into the local cache after persist", async () => {
const { a, b } = await makeTwoConsents();
await a.grant("analytics");
await b.grant("marketing");
// b re-read the freshest state during persist, so it now sees a's grant.
expect(b.isGranted("analytics")).toBe(true);
expect(b.isGranted("marketing")).toBe(true);
});
it("truly concurrent grants both survive when the second read lands after the first write", async () => {
const mock = makePayloadMock();
const a = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
const b = new PayloadConsent(
"user_1",
{} as never,
new RecordingAuditLog(),
mock.getPayload,
);
await a.load();
await b.load();
// Gate b's persist-read until a's write has committed — the ordering the
// read-merge-write strategy is designed for. (A same-window overlap is
// the documented residual race.)
let releaseB: () => void = () => {};
const bGate = new Promise<void>((resolve) => {
releaseB = resolve;
});
const originalFindByID = mock.findByID.getMockImplementation()!;
let firstPersistRead = true;
// a loads+persists first; instrument findByID so b's persist read waits.
mock.findByID.mockImplementation(async (args: { id: string }) => {
if (!firstPersistRead) await bGate;
firstPersistRead = false;
return originalFindByID(args);
});
const aDone = a.grant("analytics").then(() => releaseB());
const bDone = b.grant("marketing");
await Promise.all([aDone, bDone]);
expect(storedCategories(mock.db)).toEqual(["analytics", "marketing"]);
});
});

View File

@@ -87,7 +87,7 @@ export class PayloadConsent implements IConsent {
method: meta?.method,
};
this.cache.set(category, entry);
await this.persist();
await this.persist([category]);
await this.auditLog.record({
actorId: this.userId,
actorType: "user",
@@ -116,7 +116,7 @@ export class PayloadConsent implements IConsent {
withdrawnAt: now,
};
this.cache.set(category, entry);
await this.persist();
await this.persist([category]);
await this.auditLog.record({
actorId: this.userId,
actorType: "user",
@@ -139,9 +139,54 @@ export class PayloadConsent implements IConsent {
return Array.from(this.cache.values());
}
private async persist(): Promise<void> {
/**
* Read-merge-write persistence (audit finding A7 — lost-update race).
*
* Payload's `update` on a json field replaces the WHOLE value; there is no
* targeted array-element patch. Writing this instance's per-request cache
* verbatim would drop any category another request persisted since our
* `load()`. Instead we re-read the freshest stored state immediately
* before writing and overlay ONLY the categories this call mutated, so
* two interleaved writers touching different categories both survive.
*
* Residual window (documented, accepted): between this read and the write,
* a concurrent writer to the SAME category is last-writer-wins, and a
* concurrent writer to a different category that lands inside the window
* can still be overwritten. Closing it fully needs a DB-level transaction
* or JSON-patch support in Payload; for consent state (idempotent,
* per-subject, low frequency) read-merge-write is the accepted trade-off.
*/
private async persist(mutated: ConsentCategory[]): Promise<void> {
const payload = await this.getPayloadFn({ config: this.config });
const state = Array.from(this.cache.values()).map((entry) => ({
// Freshest stored state, immediately before the write.
const doc = await payload.findByID({
collection: "users",
id: this.userId,
overrideAccess: true,
});
const merged = new Map<ConsentCategory, UserConsentState>();
const rawState = doc["consentState"];
if (Array.isArray(rawState)) {
for (const raw of rawState) {
if (raw && typeof raw === "object") {
const entry = deserializeEntry(raw as Record<string, unknown>);
merged.set(entry.category, entry);
}
}
}
// Overlay only what this call changed.
for (const category of mutated) {
const entry = this.cache.get(category);
if (entry) merged.set(category, entry);
}
// Adopt the merged view locally so isGranted/getCategories reflect
// concurrent writers' entries too.
this.cache = merged;
const state = Array.from(merged.values()).map((entry) => ({
category: entry.category,
state: entry.state,
grantedAt: entry.grantedAt?.toISOString() ?? null,

View File

@@ -295,15 +295,40 @@ describe("dsrRouter subject scoping (A1 — IDOR)", () => {
});
});
describe("dsrRouter singleton guard", () => {
it("throws when procedures are called without a real DsrBinding", 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.
describe("dsrRouter singleton (context-time binding, A11)", () => {
it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", async () => {
const caller = dsrRouter.createCaller({
user: authenticatedUser,
} as Record<string, unknown>);
await expect(
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);
});
});

View File

@@ -97,6 +97,152 @@ describe("PayloadDataDelete", () => {
);
});
it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
deletedAt: expect.any(String),
processingRestrictedAt: expect.any(String),
}),
}),
);
});
it("does NOT stamp deletedAt without a postDeletion retention policy", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(updateData).not.toHaveProperty("deletedAt");
});
it("stamps deletedAt on owner rows of postDeletion collections", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P90D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(updateData["deletedAt"]).toEqual(expect.any(String));
expect(updateData).not.toHaveProperty("processingRestrictedAt");
});
it("redacts the auth-injected email field for a users-shaped collection (A5)", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: {
email: {
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: true,
},
username: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
displayName: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "alice@example.com", username: "alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: null,
username: null,
displayName: null,
}),
}),
);
expect(cert.affected[0]?.fields).toEqual(
expect.arrayContaining(["email", "username", "displayName"]),
);
});
it("owner role: does NOT set processingRestrictedAt", async () => {
const config = makeMockConfig([
{
@@ -367,3 +513,67 @@ describe("PayloadDataDelete", () => {
});
});
});
describe("cascade-hard audit erasure (A6)", () => {
it("calls auditErasure.eraseSubject with pseudonymize after cascade-hard", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = {
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
const eraseSubject = vi.fn().mockResolvedValue(undefined);
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
],
} as unknown as SanitizedConfig;
const deleter = new PayloadDataDelete(
config,
auditLog,
vi.fn().mockResolvedValue(mockPayload),
{ eraseSubject },
);
await deleter.deleteSubjectData("alice", "cascade-hard");
expect(eraseSubject).toHaveBeenCalledWith("alice", "pseudonymize");
});
it("does not erase audit entries on soft delete", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = {
find: vi.fn().mockResolvedValue({ docs: [{ id: "alice" }] }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
const eraseSubject = vi.fn().mockResolvedValue(undefined);
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
],
} as unknown as SanitizedConfig;
const deleter = new PayloadDataDelete(
config,
auditLog,
vi.fn().mockResolvedValue(mockPayload),
{ eraseSubject },
);
await deleter.deleteSubjectData("alice", "soft");
expect(eraseSubject).not.toHaveBeenCalled();
});
});

View File

@@ -72,6 +72,60 @@ describe("PayloadDataExport", () => {
expect(bundle.data["users"]?.asReference).toBeUndefined();
});
it("exports the auth-injected email field for a users-shaped collection (A5)", async () => {
// Mirrors packages/auth users collection: email is auto-added by Payload
// `auth: true` and declared only in the collection-level custom.pii map.
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: {
email: {
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: true,
},
username: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
displayName: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [
{
id: "alice",
email: "alice@example.com",
username: "alice",
displayName: "Alice",
passwordHash: "secret-hash",
},
],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
const row = bundle.data["users"]!.asSelf![0]!;
expect(row["email"]).toBe("alice@example.com");
expect(row["username"]).toBe("alice");
expect(row["displayName"]).toBe("Alice");
expect(row).not.toHaveProperty("passwordHash");
});
it("happy path — owner role: includes exportable PII fields", async () => {
const config = makeMockConfig([
{
@@ -207,3 +261,103 @@ describe("PayloadDataExport", () => {
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
});
});
describe("PayloadDataExport — audit log in the bundle (A14)", () => {
it("populates bundle.auditLog from the audit-logs collection, scoped to the subject", async () => {
const auditLog = new RecordingAuditLog();
const find = vi.fn(async (args: { collection: string }) => {
if (args.collection === "audit-logs") {
return {
docs: [
{
id: "log-1",
actorId: "alice",
actorType: "user",
actorRoles: ["author"],
action: "EXPORT",
resourceType: "subject-data",
resourceId: null,
changedFields: null,
scopeFeature: "core-dsr",
scopeEnvironment: "test",
scopeTenant: "default",
reason: null,
correlationId: "corr-1",
requestId: null,
ipTruncated: "system",
userAgent: "system",
containsPii: false,
piiCategories: null,
outcome: "success",
errorCode: null,
createdAt: "2026-01-01T00:00:00.000Z",
},
],
};
}
return { docs: [{ id: "alice", email: "a@ex.com" }] };
});
const getPayload = vi.fn(async () => ({ find }));
const config = {
collections: [
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{ slug: "audit-logs" },
],
} as unknown as SanitizedConfig;
const exporter = new PayloadDataExport(config, auditLog, getPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(find).toHaveBeenCalledWith(
expect.objectContaining({
collection: "audit-logs",
where: { actorId: { equals: "alice" } },
}),
);
expect(bundle.auditLog).toHaveLength(1);
const entry = bundle.auditLog![0]!;
expect(entry.actorId).toBe("alice");
expect(entry.action).toBe("EXPORT");
expect(entry.correlationId).toBe("corr-1");
expect(entry.at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
expect(entry.scope).toEqual({
feature: "core-dsr",
environment: "test",
tenant: "default",
});
});
it("leaves bundle.auditLog undefined when the audit-logs collection is absent", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
const auditLog = new RecordingAuditLog();
const mock = makeMockPayload();
mock.find.mockResolvedValue({ docs: [{ id: "alice", email: "x" }] });
const exporter = new PayloadDataExport(
config,
auditLog,
vi.fn().mockResolvedValue(mock),
);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.auditLog).toBeUndefined();
// no stray find against a non-registered collection
expect(
mock.find.mock.calls.some(
(c) => (c[0] as { collection: string }).collection === "audit-logs",
),
).toBe(false);
});
});

View File

@@ -5,7 +5,7 @@ import type { IDataDelete } from "../data-delete.interface";
import type { IDataRectify } from "../data-rectify.interface";
import type { IProcessingRestriction } from "../processing-restriction.interface";
import { PayloadDataExport } from "../payload-data-export";
import { PayloadDataDelete } from "../payload-data-delete";
import { PayloadDataDelete, type AuditErasure } from "../payload-data-delete";
import { PayloadDataRectify } from "../payload-data-rectify";
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
@@ -19,6 +19,12 @@ export type DsrBinding = {
export type BindProductionDsrOpts = {
config: SanitizedConfig;
auditLog?: AuditLogProtocol;
/**
* Privileged audit-erasure surface (core-audit's IAuditLog satisfies it).
* When present, cascade-hard deletions pseudonymize the subject's
* audit-log entries (A6).
*/
auditErasure?: AuditErasure;
};
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
@@ -34,7 +40,12 @@ export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
const auditLog = opts.auditLog ?? noopAuditLog;
return {
dataExport: new PayloadDataExport(opts.config, auditLog),
dataDelete: new PayloadDataDelete(opts.config, auditLog),
dataDelete: new PayloadDataDelete(
opts.config,
auditLog,
undefined,
opts.auditErasure,
),
dataRectify: new PayloadDataRectify(opts.config, auditLog),
processingRestriction: new PayloadProcessingRestriction(
opts.config,

View File

@@ -51,21 +51,43 @@ function userFromCtx(ctx: object): DsrTrpcUser {
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.
*
* Capture `binding` at router-creation time. Apps that mount this router
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
* A11 — the mounted router must be live, not a dead stub), falling back to
* the optional `binding` captured at router-creation time.
*
* @example
* ```ts
* // creation-time binding
* const binding = bindProductionDsr({ config, auditLog });
* 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) {
// Handlers are created lazily (inside procedure closures) so that the
// dsrRouter singleton proxy doesn't trigger at module init time.
export function createDsrRouter(binding?: DsrBinding) {
return t.router({
export: dsrProcedure
.input(
@@ -78,7 +100,8 @@ export function createDsrRouter(binding: DsrBinding) {
)
.query(async ({ ctx, input }) => {
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;
}),
@@ -100,7 +123,8 @@ export function createDsrRouter(binding: DsrBinding) {
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;
}),
@@ -117,8 +141,9 @@ export function createDsrRouter(binding: DsrBinding) {
)
.mutation(async ({ ctx, input }) => {
assertSubjectScope(userFromCtx(ctx), input.subjectId);
const b = bindingFromCtx(ctx, binding);
// 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,
);
return res.body;
@@ -135,29 +160,19 @@ export function createDsrRouter(binding: DsrBinding) {
)
.mutation(async ({ ctx, input }) => {
assertSubjectScope(userFromCtx(ctx), input.subjectId);
const res = await createRestrictHandler(binding.processingRestriction)(
input,
);
const b = bindingFromCtx(ctx, binding);
const res = await createRestrictHandler(b.processingRestriction)(input);
return res.body;
}),
});
}
/**
* Convenience singleton for projects with a single DSR binding instance.
* Most callers should use `createDsrRouter(binding)` and pass the binding
* explicitly. This export exists for type inference (`DsrRouter`) only.
* Router singleton mounted by the app router. It has no creation-time
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
* `createContext` supplies per request (A11). Calls without a context
* binding fail with INTERNAL_SERVER_ERROR at request time.
*/
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 const dsrRouter = createDsrRouter();
export type DsrRouter = ReturnType<typeof createDsrRouter>;

View File

@@ -24,6 +24,7 @@ export type {
export { PayloadDataExport } from "./payload-data-export";
export { PayloadDataDelete } from "./payload-data-delete";
export type { AuditErasure } from "./payload-data-delete";
export { PayloadDataRectify } from "./payload-data-rectify";
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
@@ -37,7 +38,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
export { bindDevSeedDsr } from "./di/bind-dev-seed";
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 { createExportHandler } from "./handlers/export-handler";

View File

@@ -1,8 +1,11 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import { randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
import { randomUUID, createHmac } from "node:crypto";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import {
RETENTION_TOMBSTONE_FIELD,
hasPostDeletionPolicy,
} from "@repo/core-shared/payload";
import type { IDataDelete } from "./data-delete.interface";
import type {
DeletionMode,
@@ -35,6 +38,15 @@ type PayloadAPI = {
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* Privileged audit-erasure surface (structural subset of core-audit's
* IAuditLog — core-dsr must not depend on the optional audit package).
* Wired by the app binder; used on the cascade-hard path (A6).
*/
export type AuditErasure = {
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
};
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
return field === "id"
? { id: { equals: subjectId } }
@@ -108,6 +120,7 @@ export class PayloadDataDelete implements IDataDelete {
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
private readonly auditErasure?: AuditErasure,
) {}
async deleteSubjectData(
@@ -142,6 +155,7 @@ export class PayloadDataDelete implements IDataDelete {
subjectId,
correlationId,
affected,
hasPostDeletionPolicy(collection),
);
} else {
await this.processReferenceRows(
@@ -157,6 +171,14 @@ export class PayloadDataDelete implements IDataDelete {
}
}
if (mode === "cascade-hard" && this.auditErasure) {
// Erase the subject's audit-log linkage (A6): pseudonymize rather than
// delete so the audit trail keeps its shape for compliance sampling.
// The users afterDelete hook covers Payload-initiated deletes; this
// covers the DSR cascade explicitly and is idempotent with the hook.
await this.auditErasure.eraseSubject(subjectId, "pseudonymize");
}
return this.buildCertificate(
subjectId,
mode,
@@ -175,6 +197,7 @@ export class PayloadDataDelete implements IDataDelete {
subjectId: string,
correlationId: string,
affected: DeletionAffected[],
postDeletionPolicy: boolean,
): Promise<void> {
const piiMeta = custom.pii ?? {};
const exportableFields = Object.entries(piiMeta)
@@ -183,10 +206,17 @@ export class PayloadDataDelete implements IDataDelete {
if (mode === "soft") {
const kind = custom.subject?.kind;
const nowIso = new Date().toISOString();
const extraData: Record<string, unknown> =
kind === "self"
? { processingRestrictedAt: new Date().toISOString() }
: {};
kind === "self" ? { processingRestrictedAt: nowIso } : {};
if (postDeletionPolicy) {
// Soft-delete tombstone (A2): collections with a
// custom.retention.postDeletion policy get stamped so the retention
// purge job can hard-delete/pseudonymize them once the grace period
// elapses. Kept separate from processingRestrictedAt — an Art. 18
// restriction alone must never trigger the purge.
extraData[RETENTION_TOMBSTONE_FIELD] = nowIso;
}
await softRedactOwnerRows(
payload,
slug,
@@ -290,9 +320,20 @@ export class PayloadDataDelete implements IDataDelete {
correlationId: string,
affected: DeletionAffected[],
): DeletionCertificate {
// Salted, keyed pseudonym (audit finding A13): the certificate used to
// hash the raw subjectId with NO salt (truncated to 64 bits), letting a
// certificate holder brute-force small id spaces offline. Now
// HMAC-SHA256 keyed by the operator secret AUDIT_PSEUDONYM_SALT (the
// same secret the audit-log pseudonymizer uses), truncated to 128 bits.
// NOTE: this changes tokens on FUTURE certificates only — certificates
// issued under the old scheme keep their historical value, and rotating
// the key likewise affects only certificates issued afterwards.
const certKey =
process.env["AUDIT_PSEUDONYM_SALT"] ??
"dev-fallback-salt-replace-in-prod";
const certSubjectId =
mode === "cascade-hard"
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
? `erased-${createHmac("sha256", certKey).update(subjectId).digest("hex").slice(0, 32)}`
: subjectId;
return {

View File

@@ -1,6 +1,7 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { AuditEntry } from "@repo/core-shared/audit";
import type { IDataExport } from "./data-export.interface";
import type {
DsrFormat,
@@ -42,6 +43,54 @@ type PayloadAPI = {
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
const AUDIT_LOGS_SLUG = "audit-logs";
/** Reconstruct an AuditEntry from its flat audit-logs collection row. */
function docToAuditEntry(doc: PayloadDoc): AuditEntry {
const str = (v: unknown): string => (v == null ? "" : String(v));
const opt = (v: unknown): string | undefined =>
v == null ? undefined : String(v);
const entry: AuditEntry = {
actorId: str(doc["actorId"]),
actorType: (doc["actorType"] as AuditEntry["actorType"]) ?? "user",
actorRoles: Array.isArray(doc["actorRoles"])
? (doc["actorRoles"] as string[])
: [],
action: doc["action"] as AuditEntry["action"],
resource: {
type: str(doc["resourceType"]),
...(doc["resourceId"] != null ? { id: String(doc["resourceId"]) } : {}),
},
at: new Date(str(doc["createdAt"])),
scope: {
feature: str(doc["scopeFeature"]),
environment: str(doc["scopeEnvironment"]),
tenant: str(doc["scopeTenant"]),
},
from: {
ipTruncated: str(doc["ipTruncated"]),
userAgent: str(doc["userAgent"]),
},
containsPii: Boolean(doc["containsPii"]),
outcome: (doc["outcome"] as AuditEntry["outcome"]) ?? "success",
};
if (Array.isArray(doc["changedFields"])) {
entry.changedFields = doc["changedFields"] as string[];
}
if (Array.isArray(doc["piiCategories"])) {
entry.piiCategories = doc["piiCategories"] as string[];
}
const reason = opt(doc["reason"]);
if (reason) entry.reason = reason;
const correlationId = opt(doc["correlationId"]);
if (correlationId) entry.correlationId = correlationId;
const requestId = opt(doc["requestId"]);
if (requestId) entry.requestId = requestId;
const errorCode = opt(doc["errorCode"]);
if (errorCode) entry.errorCode = errorCode;
return entry;
}
/**
* Payload-backed IDataExport. Walks all collections annotated with
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
@@ -112,6 +161,21 @@ export class PayloadDataExport implements IDataExport {
}
}
// GDPR Art. 15(1) includes the processing record: populate the subject's
// audit-log entries when the local audit sink is registered (audit
// finding A14). Scoped strictly to actorId === subjectId; absent
// collection (audit core not scaffolded) → field stays undefined.
let auditEntries: AuditEntry[] | undefined;
if (this.config.collections.some((c) => c.slug === AUDIT_LOGS_SLUG)) {
const result = await payload.find({
collection: AUDIT_LOGS_SLUG,
where: { actorId: { equals: subjectId } },
overrideAccess: true,
limit: 1000,
});
auditEntries = result.docs.map(docToAuditEntry);
}
await this.auditLog.record({
actorId: subjectId,
actorType: "user",
@@ -136,6 +200,10 @@ export class PayloadDataExport implements IDataExport {
data,
};
if (auditEntries) {
bundle.auditLog = auditEntries;
}
if (format === "json-ld") {
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
}

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

@@ -46,4 +46,44 @@ describe("createTrpcContext", () => {
clientIp: undefined,
});
});
it("attaches the resolved user and mirrors userId (A11)", async () => {
const req = new Request("https://example.test/api/trpc");
const ctx = await createTrpcContext(req, {
resolveUser: async () => ({ id: "user-1", roles: ["admin"] }),
});
expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] });
expect(ctx.userId).toBe("user-1");
});
it("treats a null resolver result as anonymous", async () => {
const req = new Request("https://example.test/api/trpc");
const ctx = await createTrpcContext(req, {
resolveUser: async () => null,
});
expect(ctx.user).toBeUndefined();
expect(ctx.userId).toBeUndefined();
});
it("treats a throwing resolver as anonymous instead of failing", async () => {
const req = new Request("https://example.test/api/trpc");
const ctx = await createTrpcContext(req, {
resolveUser: async () => {
throw new Error("expired session");
},
});
expect(ctx.user).toBeUndefined();
expect(ctx.clientIp).toBeUndefined();
});
it("does not invoke the resolver without a request", async () => {
let called = false;
await createTrpcContext(undefined, {
resolveUser: async () => {
called = true;
return null;
},
});
expect(called).toBe(false);
});
});

View File

@@ -20,13 +20,49 @@ export function clientIpFromHeaders(headers: Headers): string | undefined {
}
/**
* Build the per-request tRPC context. Pass the adapter's incoming fetch
* `Request` so server-derived fields (currently `clientIp`) are attached —
* procedures must never trust client-supplied equivalents (B2).
* Server-resolved authenticated user attached to the tRPC context.
* Resolved from the app's session mechanism (never from client input);
* `roles` is a snapshot for role-gated procedures (admin checks).
*/
export async function createTrpcContext(req?: Request) {
export type TrpcSessionUser = {
id: string;
roles: string[];
};
export type CreateTrpcContextOpts = {
/**
* App-provided session resolver (audit finding A11). Receives the incoming
* request and returns the authenticated user, or null/undefined for
* anonymous callers. A throwing resolver is treated as anonymous — an
* expired or malformed session cookie must not 500 public queries;
* procedures that need a user reject with UNAUTHORIZED instead.
*/
resolveUser?: (req: Request) => Promise<TrpcSessionUser | null | undefined>;
};
/**
* Build the per-request tRPC context. Pass the adapter's incoming fetch
* `Request` so server-derived fields (`clientIp`, and — when the app supplies
* a `resolveUser` — the authenticated `user`) are attached. Procedures must
* never trust client-supplied equivalents (B2).
*/
export async function createTrpcContext(
req?: Request,
opts: CreateTrpcContextOpts = {},
) {
let user: TrpcSessionUser | undefined;
if (req && opts.resolveUser) {
try {
user = (await opts.resolveUser(req)) ?? undefined;
} catch {
user = undefined;
}
}
return {
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
user,
/** Convenience mirror of `user.id` (consumed by the consent router). */
userId: user?.id,
};
}

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);

View File

@@ -1,5 +1,6 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated";
import { MediaNotFoundError } from "../../entities/errors/media";
import { InputParseError } from "../../entities/errors/common";
@@ -10,3 +11,10 @@ export const mediaProcedure = t.procedure.use(
[MediaNotFoundError, "NOT_FOUND"],
]),
);
/**
* Base procedure for MUTATING media routes (audit finding B7): anonymous
* callers are rejected with UNAUTHORIZED before the controller runs.
* Read-only queries stay on `mediaProcedure`.
*/
export const mediaProtectedProcedure = mediaProcedure.use(requireAuthenticated);

View File

@@ -36,6 +36,29 @@ describe("mediaRouter", () => {
});
});
describe("mediaRouter authorization (B7)", () => {
beforeEach(() => {
mediaContainer.unbindAll();
mediaContainer.load(MediaModule);
});
afterEach(() => {
mediaContainer.unbindAll();
});
it("deleteMedia rejects anonymous callers with UNAUTHORIZED", async () => {
const caller = mediaRouter.createCaller({});
await expect(caller.deleteMedia({ id: "some-id" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("read-only queries stay public", async () => {
const caller = mediaRouter.createCaller({});
await expect(caller.listMedia({})).resolves.toEqual([]);
});
});
describe("mediaRouter error mapping", () => {
beforeEach(() => {
mediaContainer.unbindAll();
@@ -69,7 +92,8 @@ describe("mediaRouter error mapping", () => {
});
it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => {
const caller = mediaRouter.createCaller({});
// Mutations are auth-gated (B7) — provide a server-resolved ctx.user.
const caller = mediaRouter.createCaller({ user: { id: "u1", roles: [] } });
try {
await caller.deleteMedia({ id: "nonexistent-id" });
throw new Error("expected throw");

View File

@@ -11,21 +11,30 @@ import type { IGetMediaController } from "../../interface-adapters/controllers/g
import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller";
import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller";
import { mediaProcedure } from "./procedures";
import { mediaProcedure, mediaProtectedProcedure } from "./procedures";
export const mediaRouter = router({
getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => {
const ctrl = mediaContainer.get<IGetMediaController>(MEDIA_SYMBOLS.IGetMediaController);
const ctrl = mediaContainer.get<IGetMediaController>(
MEDIA_SYMBOLS.IGetMediaController,
);
return ctrl(input);
}),
listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
return ctrl(input);
}),
deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
const ctrl = mediaContainer.get<IListMediaController>(
MEDIA_SYMBOLS.IListMediaController,
);
return ctrl(input);
}),
// Mutations require an authenticated caller (B7).
deleteMedia: mediaProtectedProcedure
.input(deleteMediaInputSchema)
.mutation(({ input }) => {
const ctrl = mediaContainer.get<IDeleteMediaController>(
MEDIA_SYMBOLS.IDeleteMediaController,
);
return ctrl(input);
}),
});
export type MediaRouter = typeof mediaRouter;

15
pnpm-lock.yaml generated
View File

@@ -188,9 +188,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
@@ -618,6 +627,12 @@ importers:
"@repo/blog":
specifier: workspace:*
version: link:../blog
"@repo/core-audit":
specifier: workspace:*
version: link:../core-audit
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
"@repo/marketing-pages":
specifier: workspace:*
version: link:../marketing-pages