Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import type { SanitizedConfig } from "payload";
import { bindProductionDsr } from "@/di/bind-production";
import { bindDevSeedDsr } from "@/di/bind-dev-seed";
const emptyConfig = { collections: [] } as unknown as SanitizedConfig;
describe("bindProductionDsr", () => {
it("returns a DsrBinding with all four implementations", () => {
const binding = bindProductionDsr({ config: emptyConfig });
expect(binding.dataExport).toBeDefined();
expect(binding.dataDelete).toBeDefined();
expect(binding.dataRectify).toBeDefined();
expect(binding.processingRestriction).toBeDefined();
});
it("each binding exposes the expected interface methods", () => {
const binding = bindProductionDsr({ config: emptyConfig });
expect(typeof binding.dataExport.exportSubjectData).toBe("function");
expect(typeof binding.dataDelete.deleteSubjectData).toBe("function");
expect(typeof binding.dataRectify.updateSubjectField).toBe("function");
expect(typeof binding.processingRestriction.setRestriction).toBe(
"function",
);
expect(typeof binding.processingRestriction.isRestricted).toBe("function");
});
it("uses the noop auditLog when none is provided", () => {
expect(() => bindProductionDsr({ config: emptyConfig })).not.toThrow();
});
});
describe("bindDevSeedDsr", () => {
it("returns a DsrBinding with all four in-memory implementations", () => {
const binding = bindDevSeedDsr();
expect(binding.dataExport).toBeDefined();
expect(binding.dataDelete).toBeDefined();
expect(binding.dataRectify).toBeDefined();
expect(binding.processingRestriction).toBeDefined();
});
it("each binding exposes the expected interface methods", () => {
const binding = bindDevSeedDsr();
expect(typeof binding.dataExport.exportSubjectData).toBe("function");
expect(typeof binding.dataDelete.deleteSubjectData).toBe("function");
expect(typeof binding.dataRectify.updateSubjectField).toBe("function");
expect(typeof binding.processingRestriction.setRestriction).toBe(
"function",
);
expect(typeof binding.processingRestriction.isRestricted).toBe("function");
});
});

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, vi } from "vitest";
import { createDsrRouter, dsrRouter } from "@/dsr.router";
import type { DsrTrpcUser } from "@/dsr.router";
import type { DsrBinding } from "@/di/bind-production";
import {
RecordingDataExport,
RecordingDataDelete,
RecordingDataRectify,
RecordingProcessingRestriction,
} from "@repo/core-testing/instrumentation";
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
// The alias types are structurally compatible at runtime; cast via unknown to
// satisfy the DsrBinding constraint without modifying core-testing.
function makeBinding() {
return {
dataExport: new RecordingDataExport(),
dataDelete: new RecordingDataDelete(),
dataRectify: new RecordingDataRectify(),
processingRestriction: new RecordingProcessingRestriction(),
};
}
type TestBinding = ReturnType<typeof makeBinding>;
function makeCaller(binding: TestBinding, user?: DsrTrpcUser) {
const router = createDsrRouter(binding as unknown as DsrBinding);
return router.createCaller({ user } as Record<string, unknown>);
}
const authenticatedUser: DsrTrpcUser = { id: "alice", roles: ["user"] };
const adminUser: DsrTrpcUser = { id: "admin-user", roles: ["admin"] };
describe("dsrRouter.export", () => {
it("returns UserDataBundle body for authenticated user", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
const result = await caller.export({ subjectId: "alice", format: "json" });
expect(result.subjectId).toBe("alice");
expect(result.format).toBe("json");
expect(binding.dataExport.calls).toHaveLength(1);
});
it("works with json-ld format", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
const result = await caller.export({
subjectId: "alice",
format: "json-ld",
});
expect(result.format).toBe("json-ld");
});
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, undefined);
await expect(
caller.export({ subjectId: "alice", format: "json" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("propagates errors from dataExport", async () => {
const binding = makeBinding();
vi.spyOn(binding.dataExport, "exportSubjectData").mockRejectedValue(
new Error("export failed"),
);
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.export({ subjectId: "alice", format: "json" }),
).rejects.toThrow("export failed");
});
});
describe("dsrRouter.delete", () => {
it("returns DeletionCertificate for soft mode (any authenticated user)", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
const result = await caller.delete({ subjectId: "alice", mode: "soft" });
expect(result.subjectId).toBe("alice");
expect(result.mode).toBe("soft");
expect(binding.dataDelete.calls).toHaveLength(1);
});
it("returns DeletionCertificate for cascade-hard mode with admin user", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, adminUser);
const result = await caller.delete({
subjectId: "alice",
mode: "cascade-hard",
});
expect(result.mode).toBe("cascade-hard");
expect(binding.dataDelete.calls[0]?.mode).toBe("cascade-hard");
});
it("throws FORBIDDEN for cascade-hard mode with non-admin user", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(binding.dataDelete.calls).toHaveLength(0);
});
it("throws FORBIDDEN for cascade-hard when user has no roles", async () => {
const binding = makeBinding();
const noRolesUser: DsrTrpcUser = { id: "alice" };
const caller = makeCaller(binding, noRolesUser);
await expect(
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, undefined);
await expect(
caller.delete({ subjectId: "alice", mode: "soft" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsrRouter.rectify", () => {
it("returns { ok: true } for authenticated user", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
const result = await caller.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice New",
});
expect(result).toEqual({ ok: true });
expect(binding.dataRectify.calls[0]).toEqual({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice New",
});
});
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, undefined);
await expect(
caller.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "x",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("propagates errors from dataRectify", async () => {
const binding = makeBinding();
vi.spyOn(binding.dataRectify, "updateSubjectField").mockRejectedValue(
new Error('Field "secret" is not tagged as PII'),
);
const caller = makeCaller(binding, authenticatedUser);
await expect(
caller.rectify({
subjectId: "alice",
collection: "users",
field: "secret",
value: "x",
}),
).rejects.toThrow();
});
});
describe("dsrRouter.restrict", () => {
it("returns { ok: true } when granting restriction", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
const result = await caller.restrict({
subjectId: "alice",
granted: true,
});
expect(result).toEqual({ ok: true });
expect(binding.processingRestriction.sets[0]).toEqual({
subjectId: "alice",
granted: true,
});
});
it("returns { ok: true } when lifting restriction", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, authenticatedUser);
const result = await caller.restrict({
subjectId: "alice",
granted: false,
});
expect(result).toEqual({ ok: true });
expect(binding.processingRestriction.sets[0]?.granted).toBe(false);
});
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
const binding = makeBinding();
const caller = makeCaller(binding, undefined);
await expect(
caller.restrict({ subjectId: "alice", granted: true }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
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.
const caller = dsrRouter.createCaller({
user: authenticatedUser,
} as Record<string, unknown>);
await expect(
caller.export({ subjectId: "alice", format: "json" }),
).rejects.toThrow(/dsrRouter singleton/);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { createExportHandler } from "@/handlers/export-handler";
import { createDeleteHandler } from "@/handlers/delete-handler";
import { createRectifyHandler } from "@/handlers/rectify-handler";
import { createRestrictHandler } from "@/handlers/restrict-handler";
import type { IDataExport } from "@/data-export.interface";
import type { IDataDelete } from "@/data-delete.interface";
import type { IDataRectify } from "@/data-rectify.interface";
import type { IProcessingRestriction } from "@/processing-restriction.interface";
import {
RecordingDataExport,
RecordingDataDelete,
RecordingDataRectify,
RecordingProcessingRestriction,
} from "@repo/core-testing/instrumentation";
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
// Cast via unknown so they satisfy the interface at the TypeScript level while
// remaining structurally compatible at runtime.
describe("createExportHandler", () => {
it("calls exportSubjectData and returns status 200 with bundle body", async () => {
const dataExport = new RecordingDataExport();
const handler = createExportHandler(dataExport as unknown as IDataExport);
const res = await handler({ subjectId: "alice", format: "json" });
expect(res.status).toBe(200);
expect(res.body.subjectId).toBe("alice");
expect(res.body.format).toBe("json");
expect(dataExport.calls).toHaveLength(1);
expect(dataExport.calls[0]).toEqual({ subjectId: "alice", format: "json" });
});
it("sets Content-Type: application/json for json format", async () => {
const dataExport = new RecordingDataExport();
const handler = createExportHandler(dataExport as unknown as IDataExport);
const res = await handler({ subjectId: "alice", format: "json" });
expect(res.headers?.["Content-Type"]).toBe("application/json");
});
it("sets Content-Type: application/ld+json for json-ld format", async () => {
const dataExport = new RecordingDataExport();
const handler = createExportHandler(dataExport as unknown as IDataExport);
const res = await handler({ subjectId: "alice", format: "json-ld" });
expect(res.headers?.["Content-Type"]).toBe("application/ld+json");
});
});
describe("createDeleteHandler", () => {
it("calls deleteSubjectData and returns status 200 with certificate body", async () => {
const dataDelete = new RecordingDataDelete();
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
const res = await handler({ subjectId: "alice", mode: "soft" });
expect(res.status).toBe(200);
expect(res.body.subjectId).toBe("alice");
expect(res.body.mode).toBe("soft");
expect(dataDelete.calls).toHaveLength(1);
expect(dataDelete.calls[0]).toEqual({ subjectId: "alice", mode: "soft" });
});
it("passes cascade-hard mode to deleteSubjectData", async () => {
const dataDelete = new RecordingDataDelete();
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
const res = await handler({ subjectId: "alice", mode: "cascade-hard" });
expect(res.body.mode).toBe("cascade-hard");
expect(dataDelete.calls[0]?.mode).toBe("cascade-hard");
});
});
describe("createRectifyHandler", () => {
it("calls updateSubjectField and returns status 200 with ok body", async () => {
const dataRectify = new RecordingDataRectify();
const handler = createRectifyHandler(
dataRectify as unknown as IDataRectify,
);
const res = await handler({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice New",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(dataRectify.calls).toHaveLength(1);
expect(dataRectify.calls[0]).toEqual({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice New",
});
});
});
describe("createRestrictHandler", () => {
it("calls setRestriction with granted=true and returns status 200", async () => {
const processingRestriction = new RecordingProcessingRestriction();
const handler = createRestrictHandler(
processingRestriction as unknown as IProcessingRestriction,
);
const res = await handler({ subjectId: "alice", granted: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(processingRestriction.sets).toHaveLength(1);
expect(processingRestriction.sets[0]).toEqual({
subjectId: "alice",
granted: true,
});
});
it("calls setRestriction with granted=false", async () => {
const processingRestriction = new RecordingProcessingRestriction();
const handler = createRestrictHandler(
processingRestriction as unknown as IProcessingRestriction,
);
await handler({ subjectId: "alice", granted: false });
expect(processingRestriction.sets[0]).toEqual({
subjectId: "alice",
granted: false,
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { InMemoryDataExport } from "@/in-memory-data-export";
import { InMemoryDataDelete } from "@/in-memory-data-delete";
import { InMemoryDataRectify } from "@/in-memory-data-rectify";
import { InMemoryProcessingRestriction } from "@/in-memory-processing-restriction";
describe("InMemoryDataExport", () => {
it("returns an empty bundle with the correct shape", async () => {
const exporter = new InMemoryDataExport();
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.subjectId).toBe("alice");
expect(bundle.format).toBe("json");
expect(typeof bundle.exportedAt).toBe("string");
expect(bundle.data).toEqual({});
});
it("preserves format in the returned bundle", async () => {
const exporter = new InMemoryDataExport();
const bundle = await exporter.exportSubjectData("bob", "json-ld");
expect(bundle.format).toBe("json-ld");
});
});
describe("InMemoryDataDelete", () => {
it("returns a DeletionCertificate with the correct shape", async () => {
const deleter = new InMemoryDataDelete();
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("soft");
expect(cert.reason).toBe("art-17-request");
expect(cert.affected).toEqual([]);
expect(typeof cert.auditEntryId).toBe("string");
expect(typeof cert.timestamp).toBe("string");
});
it("preserves mode in the certificate", async () => {
const deleter = new InMemoryDataDelete();
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(cert.mode).toBe("cascade-hard");
});
});
describe("InMemoryDataRectify", () => {
it("resolves without error", async () => {
const rectifier = new InMemoryDataRectify();
await expect(
rectifier.updateSubjectField("alice", "users", "name", "Alice New"),
).resolves.toBeUndefined();
});
});
describe("InMemoryProcessingRestriction", () => {
it("isRestricted returns false for unknown subjects", async () => {
const restriction = new InMemoryProcessingRestriction();
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("setRestriction(true) makes isRestricted return true", async () => {
const restriction = new InMemoryProcessingRestriction();
await restriction.setRestriction("alice", true);
expect(await restriction.isRestricted("alice")).toBe(true);
});
it("setRestriction(false) makes isRestricted return false", async () => {
const restriction = new InMemoryProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("alice", false);
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("tracks restriction per subject independently", async () => {
const restriction = new InMemoryProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("bob", false);
expect(await restriction.isRestricted("alice")).toBe(true);
expect(await restriction.isRestricted("bob")).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join, dirname } from "node:path";
import { vi } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataExport } from "@/payload-data-export";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
const __dir = dirname(fileURLToPath(import.meta.url));
const contextFilePath = join(__dir, "../../src/contexts/user-data.jsonld");
function loadContextFile(): Record<string, unknown> {
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as {
"@context": Record<string, unknown>;
};
return raw["@context"];
}
describe("user-data.jsonld context", () => {
it("is valid JSON with an @context key", () => {
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as unknown;
expect(raw).toMatchObject({ "@context": expect.any(Object) });
});
it("declares @vocab pointing to schema.org", () => {
const ctx = loadContextFile();
expect(ctx["@vocab"]).toBe("https://schema.org/");
});
it("declares dsr namespace pointing to DPV", () => {
const ctx = loadContextFile();
expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#");
});
it("declares prov namespace", () => {
const ctx = loadContextFile();
expect(ctx["prov"]).toBe("https://www.w3.org/ns/prov#");
});
it("maps subjectId to schema.org identifier", () => {
const ctx = loadContextFile();
expect(ctx["subjectId"]).toBe("identifier");
});
it("maps exportedAt to schema.org dateCreated", () => {
const ctx = loadContextFile();
expect(ctx["exportedAt"]).toBe("dateCreated");
});
it("maps data as an @index container", () => {
const ctx = loadContextFile();
expect(ctx["data"]).toMatchObject({ "@container": "@index" });
});
it("maps asSelf to a DSR handling type", () => {
const ctx = loadContextFile();
expect(ctx["asSelf"]).toMatchObject({ "@type": "@id" });
});
it("maps asReference to a data subject right type", () => {
const ctx = loadContextFile();
expect(ctx["asReference"]).toMatchObject({ "@type": "@id" });
});
it("json-ld bundle includes the inline context matching the file", async () => {
const auditLog = new RecordingAuditLog();
const mockPayload = { find: vi.fn().mockResolvedValue({ docs: [] }) };
const mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
const config = { collections: [] } as unknown as SanitizedConfig;
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json-ld");
const fileCtx = loadContextFile();
const bundleCtx = bundle["@context"] as Record<string, unknown>;
// Core keys must match between the file and the inline constant
expect(bundleCtx["@vocab"]).toBe(fileCtx["@vocab"]);
expect(bundleCtx["dsr"]).toBe(fileCtx["dsr"]);
expect(bundleCtx["subjectId"]).toBe(fileCtx["subjectId"]);
expect(bundleCtx["exportedAt"]).toBe(fileCtx["exportedAt"]);
});
});

View File

@@ -0,0 +1,369 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataDelete } from "@/payload-data-delete";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return {
find: vi.fn(),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
};
}
function makeMockConfig(
collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
describe("PayloadDataDelete", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
describe("soft mode", () => {
it("happy path — self role: NULLs exportable PII fields and emits RESTRICT", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true }, name: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com", name: "Alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "users",
id: "alice",
data: expect.objectContaining({ email: null, name: null }),
overrideAccess: true,
});
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("soft");
expect(cert.affected).toHaveLength(1);
expect(cert.affected[0]).toMatchObject({
collection: "users",
action: "redacted",
fields: expect.arrayContaining(["email", "name"]),
});
});
it("self role: sets processingRestrictedAt in the update call", 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");
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
processingRestrictedAt: expect.any(String),
}),
}),
);
});
it("owner role: does NOT set processingRestrictedAt", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "orders",
id: "o-1",
data: { shippingAddress: null },
overrideAccess: true,
});
});
it("happy path — owner role: NULLs exportable fields in owned rows", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "orders",
id: "o-1",
data: { shippingAddress: null },
overrideAccess: true,
});
expect(cert.affected[0]?.action).toBe("redacted");
});
it("happy path — reference role: NULLs only the linked field, preserves row", async () => {
const config = makeMockConfig([
{
slug: "comments",
custom: {
subject: { field: "mentionedUser", kind: "reference" },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "c-1", mentionedUser: "alice", body: "hello alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
// Only the linking field is NULLed — not the body
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "comments",
id: "c-1",
data: { mentionedUser: null },
overrideAccess: true,
});
// Row is NOT deleted
expect(mockPayload.delete).not.toHaveBeenCalled();
});
it("multi-subject row: only the requesting subject's link is redacted", async () => {
// Two rows in a reference collection: one for alice, one for bob
const config = makeMockConfig([
{
slug: "comments",
custom: {
subject: { field: "mentionedUser", kind: "reference" },
},
},
]);
// find returns only alice's row (Payload's where clause filters for alice)
mockPayload.find.mockResolvedValue({
docs: [{ id: "c-alice", mentionedUser: "alice", body: "hi alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
// Only c-alice is updated
expect(mockPayload.update).toHaveBeenCalledTimes(1);
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({ id: "c-alice" }),
);
});
it("emits RESTRICT audit entries per affected collection", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
]);
mockPayload.find
.mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
.mockResolvedValueOnce({
docs: [{ id: "o-1", userId: "alice", total: 50 }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
const restrictEntries = auditLog.recorded.filter(
(e) => e.action === "RESTRICT",
);
expect(restrictEntries).toHaveLength(2);
expect(restrictEntries.map((e) => e.resource.type)).toEqual(
expect.arrayContaining(["users", "orders"]),
);
});
it("returns DeletionCertificate with correct shape", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "soft");
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("soft");
expect(cert.reason).toBe("art-17-request");
expect(cert.auditEntryId).toBeTruthy();
expect(typeof cert.timestamp).toBe("string");
});
});
describe("cascade-hard mode", () => {
it("happy path — self/owner: hard-deletes rows", 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);
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(mockPayload.delete).toHaveBeenCalledWith({
collection: "users",
id: "alice",
overrideAccess: true,
});
expect(mockPayload.update).not.toHaveBeenCalled();
expect(cert.affected[0]?.action).toBe("deleted");
});
it("cascade-hard — reference: NULLs linked field (does not delete row)", async () => {
const config = makeMockConfig([
{
slug: "comments",
custom: { subject: { field: "mentionedUser", kind: "reference" } },
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "c-1", mentionedUser: "alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "cascade-hard");
expect(mockPayload.delete).not.toHaveBeenCalled();
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { mentionedUser: null } }),
);
});
it("emits DELETE audit entries for self/owner collections", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "cascade-hard");
const deleteEntries = auditLog.recorded.filter(
(e) => e.action === "DELETE",
);
expect(deleteEntries).toHaveLength(1);
expect(deleteEntries[0]!.resource.type).toBe("users");
});
it("anonymises the subjectId in the certificate", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(cert.subjectId).toMatch(/^erased-[0-9a-f]+$/);
expect(cert.subjectId).not.toContain("alice");
});
it("correlationId is shared across all audit entries for one operation", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
]);
mockPayload.find
.mockResolvedValueOnce({ docs: [{ id: "alice" }] })
.mockResolvedValueOnce({ docs: [{ id: "o-1", userId: "alice" }] });
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
const ids = auditLog.recorded.map((e) => e.correlationId);
expect(new Set(ids).size).toBe(1);
expect(ids[0]).toBe(cert.auditEntryId);
});
});
});

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataExport } from "@/payload-data-export";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return { find: vi.fn() };
}
function makeMockConfig(
collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
describe("PayloadDataExport", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
it("returns an empty bundle when no collections have custom.subject", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.subjectId).toBe("alice");
expect(bundle.format).toBe("json");
expect(bundle.data).toEqual({});
expect(mockPayload.find).not.toHaveBeenCalled();
});
it("happy path — self role: includes exportable PII fields only", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: {
email: { exportable: true },
name: { exportable: true },
secret: { exportable: false },
},
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", email: "a@ex.com", name: "Alice", secret: "x" }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.data["users"]?.asSelf).toHaveLength(1);
const row = bundle.data["users"]!.asSelf![0]!;
expect(row).toMatchObject({
id: "alice",
email: "a@ex.com",
name: "Alice",
});
expect(row).not.toHaveProperty("secret");
expect(bundle.data["users"]?.asReference).toBeUndefined();
});
it("happy path — owner role: includes exportable PII fields", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "order-1", userId: "alice", shippingAddress: "1 Main St" }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
const row = bundle.data["orders"]?.asSelf?.[0];
expect(row).toMatchObject({ id: "order-1", shippingAddress: "1 Main St" });
});
it("happy path — reference role: returns SubjectReference, not row content", async () => {
const config = makeMockConfig([
{
slug: "comments",
custom: {
subject: { field: "mentionedUser", kind: "reference" },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "comment-42", mentionedUser: "alice", body: "hello" }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.data["comments"]?.asSelf).toBeUndefined();
const refs = bundle.data["comments"]?.asReference;
expect(refs).toHaveLength(1);
expect(refs![0]).toEqual({
rowId: "comment-42",
linkedField: "mentionedUser",
linkedThrough: "comments",
});
// Row content must NOT appear in the reference bucket
expect(JSON.stringify(refs)).not.toContain("hello");
});
it("skips collections that return no matching rows", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({ docs: [] });
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle.data).toEqual({});
});
it("emits an EXPORT audit entry", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
await exporter.exportSubjectData("alice", "json");
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("EXPORT");
expect(entry.actorId).toBe("alice");
expect(entry.resource.type).toBe("subject-data");
expect(entry.outcome).toBe("success");
});
it("attaches @context when format is json-ld", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json-ld");
expect(bundle["@context"]).toBeDefined();
expect(typeof bundle["@context"]).toBe("object");
const ctx = bundle["@context"] as Record<string, unknown>;
expect(ctx["@vocab"]).toBe("https://schema.org/");
expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#");
expect(ctx["subjectId"]).toBe("identifier");
});
it("does NOT attach @context when format is json", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(bundle["@context"]).toBeUndefined();
});
it("multi-collection: walks all subject-linked collections", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { total: { exportable: true } },
},
},
{ slug: "posts" }, // no subject linkage — should be skipped
]);
mockPayload.find
.mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
.mockResolvedValueOnce({
docs: [{ id: "o-1", userId: "alice", total: 99 }],
});
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
const bundle = await exporter.exportSubjectData("alice", "json");
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataRectify } from "@/payload-data-rectify";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return {
find: vi.fn(),
update: vi.fn().mockResolvedValue({}),
};
}
function makeMockConfig(
collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
return { collections } as unknown as SanitizedConfig;
}
describe("PayloadDataRectify", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
it("happy path: updates a PII-tagged field and emits RESTRICT with art-16-request reason", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { name: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", name: "Alice Old" }],
});
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await rectifier.updateSubjectField("alice", "users", "name", "Alice New");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "users",
id: "alice",
data: { name: "Alice New" },
overrideAccess: true,
});
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("RESTRICT");
expect(entry.reason).toBe("art-16-request");
expect(entry.actorId).toBe("alice");
expect(entry.changedFields).toContain("name");
});
it("updates all subject rows if multiple exist", async () => {
const config = makeMockConfig([
{
slug: "profiles",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { bio: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [
{ id: "p-1", userId: "alice", bio: "old" },
{ id: "p-2", userId: "alice", bio: "old2" },
],
});
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await rectifier.updateSubjectField("alice", "profiles", "bio", "new bio");
expect(mockPayload.update).toHaveBeenCalledTimes(2);
});
it("throws when collection not found in config", async () => {
const config = makeMockConfig([{ slug: "users" }]);
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await expect(
rectifier.updateSubjectField("alice", "nonexistent", "field", "val"),
).rejects.toThrow(/not found/i);
});
it("throws when collection has no DSR subject linkage", async () => {
const config = makeMockConfig([{ slug: "posts" }]);
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await expect(
rectifier.updateSubjectField("alice", "posts", "title", "new"),
).rejects.toThrow(/no DSR subject linkage/i);
});
it("throws when field is not PII-tagged", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { email: { exportable: true } },
},
},
]);
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await expect(
rectifier.updateSubjectField("alice", "users", "internalNote", "oops"),
).rejects.toThrow(/not tagged as PII/i);
});
it("emits audit entry with resource type matching the collection slug", async () => {
const config = makeMockConfig([
{
slug: "users",
custom: {
subject: { field: "id", kind: "self" },
pii: { name: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({ docs: [{ id: "alice" }] });
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
await rectifier.updateSubjectField("alice", "users", "name", "New");
expect(auditLog.recorded[0]!.resource.type).toBe("users");
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadProcessingRestriction } from "@/payload-processing-restriction";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
type MockPayload = {
find: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function makeMockPayload(): MockPayload {
return {
find: vi.fn(),
update: vi.fn().mockResolvedValue({}),
};
}
const emptyConfig = { collections: [] } as unknown as SanitizedConfig;
describe("PayloadProcessingRestriction", () => {
let auditLog: RecordingAuditLog;
let mockPayload: MockPayload;
let mockGetPayload: ReturnType<typeof vi.fn>;
beforeEach(() => {
auditLog = new RecordingAuditLog();
mockPayload = makeMockPayload();
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
});
describe("setRestriction", () => {
it("sets processingRestrictedAt when granted=true", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", true);
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
collection: "users",
id: "alice",
data: expect.objectContaining({
processingRestrictedAt: expect.any(String),
}),
overrideAccess: true,
}),
);
});
it("clears processingRestrictedAt when granted=false", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", false);
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { processingRestrictedAt: null },
}),
);
});
it("emits RESTRICT audit entry when granting restriction", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", true);
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("RESTRICT");
expect(entry.actorId).toBe("alice");
expect(entry.changedFields).toContain("processingRestrictedAt");
});
it("emits UNRESTRICT audit entry when lifting restriction", async () => {
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", false);
expect(auditLog.recorded[0]!.action).toBe("UNRESTRICT");
});
});
describe("isRestricted", () => {
it("returns true when processingRestrictedAt is set", async () => {
mockPayload.find.mockResolvedValue({
docs: [
{ id: "alice", processingRestrictedAt: "2024-01-01T00:00:00.000Z" },
],
});
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
expect(await restriction.isRestricted("alice")).toBe(true);
});
it("returns false when processingRestrictedAt is null", async () => {
mockPayload.find.mockResolvedValue({
docs: [{ id: "alice", processingRestrictedAt: null }],
});
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("returns false when user record not found", async () => {
mockPayload.find.mockResolvedValue({ docs: [] });
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
expect(await restriction.isRestricted("ghost")).toBe(false);
});
it("restriction flag honored: setRestriction(true) then isRestricted returns true", async () => {
// setRestriction updates the DB; simulate the updated state via find mock
mockPayload.find.mockResolvedValue({
docs: [
{ id: "alice", processingRestrictedAt: new Date().toISOString() },
],
});
const restriction = new PayloadProcessingRestriction(
emptyConfig,
auditLog,
mockGetPayload,
);
await restriction.setRestriction("alice", true);
const result = await restriction.isRestricted("alice");
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,39 @@
{
"@context": {
"@vocab": "https://schema.org/",
"dsr": "https://w3.org/ns/dpv#",
"prov": "https://www.w3.org/ns/prov#",
"subjectId": "identifier",
"exportedAt": "dateCreated",
"format": "encodingFormat",
"data": {
"@id": "prov:hadMember",
"@container": "@index"
},
"asSelf": {
"@id": "dsr:hasPersonalDataHandling",
"@type": "@id"
},
"asReference": {
"@id": "dsr:hasDataSubjectRight",
"@type": "@id"
},
"rowId": "identifier",
"linkedField": "name",
"linkedThrough": {
"@id": "isPartOf",
"@type": "@id"
},
"auditLog": {
"@id": "prov:wasGeneratedBy",
"@type": "@id"
},
"UserDataBundle": "dsr:RightOfAccess",
"SubjectReference": "dsr:DataSubjectRight"
}
}

View File

@@ -0,0 +1,29 @@
import type { DeletionCertificate, DeletionMode } from "./dsr-types";
/**
* GDPR Art. 17 (right to erasure / "right to be forgotten").
*
* Two modes:
*
* - `"soft"` — sets `processingRestrictedAt`, NULLs all `exportable: true`
* PII fields, and redacts `reference`-role linked fields to null. The row
* structure is preserved so other subjects' data in shared rows remains
* intact. Emits one RESTRICT audit entry per affected collection.
*
* - `"cascade-hard"` — hard-deletes `self` and `owner` rows immediately, then
* redacts `reference` fields. Auth-guarded at the procedure layer; must not
* be called from user-facing flows. Emits DELETE audit entries.
*/
export interface IDataDelete {
/**
* Delete or erase all personal data held for the given subject.
*
* @param subjectId - The subject's canonical ID.
* @param mode - Deletion strategy (soft redaction vs hard cascade).
* @returns A signed `DeletionCertificate` linking to the audit log entry.
*/
deleteSubjectData(
subjectId: string,
mode: DeletionMode,
): Promise<DeletionCertificate>;
}

View File

@@ -0,0 +1,22 @@
import type { DsrFormat, UserDataBundle } from "./dsr-types";
/**
* GDPR Art. 15 (right of access) + Art. 20 (right to data portability).
*
* Implementations walk all Payload collections with `custom.subject` linkage,
* segment rows by role (self/owner vs reference), and filter to fields marked
* `exportable: true` in `custom.pii`.
*/
export interface IDataExport {
/**
* Export all personal data held for the given subject.
*
* @param subjectId - The subject's canonical ID (e.g. users.id).
* @param format - "json" for a plain JSON bundle; "json-ld" attaches the
* @context from `contexts/user-data.jsonld`.
*/
exportSubjectData(
subjectId: string,
format: DsrFormat,
): Promise<UserDataBundle>;
}

View File

@@ -0,0 +1,24 @@
/**
* GDPR Art. 16 (right to rectification).
*
* Allows a subject to correct inaccurate personal data held about them.
* Implementations verify the field is PII-tagged before updating and emit a
* RESTRICT audit entry with `reason: "art-16-request"` as the tamper-evident
* record of the correction.
*/
export interface IDataRectify {
/**
* Update a single PII field for the given subject in the specified collection.
*
* @param subjectId - The subject's canonical ID.
* @param collection - Payload collection slug (e.g. "users").
* @param field - Name of the field to update (must be `custom.pii`-tagged).
* @param value - New value; must satisfy the field's Payload field type.
*/
updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void>;
}

View File

@@ -0,0 +1,21 @@
import { InMemoryDataExport } from "../in-memory-data-export";
import { InMemoryDataDelete } from "../in-memory-data-delete";
import { InMemoryDataRectify } from "../in-memory-data-rectify";
import { InMemoryProcessingRestriction } from "../in-memory-processing-restriction";
import type { DsrBinding } from "./bind-production";
/**
* Returns in-memory DSR implementations for dev-seed and storybook contexts
* where Payload is unavailable.
*
* No config or auditLog needed: all operations are no-ops that return
* well-shaped responses.
*/
export function bindDevSeedDsr(): DsrBinding {
return {
dataExport: new InMemoryDataExport(),
dataDelete: new InMemoryDataDelete(),
dataRectify: new InMemoryDataRectify(),
processingRestriction: new InMemoryProcessingRestriction(),
};
}

View File

@@ -0,0 +1,44 @@
import type { SanitizedConfig } from "payload";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { IDataExport } from "../data-export.interface";
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 { PayloadDataRectify } from "../payload-data-rectify";
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
export type DsrBinding = {
dataExport: IDataExport;
dataDelete: IDataDelete;
dataRectify: IDataRectify;
processingRestriction: IProcessingRestriction;
};
export type BindProductionDsrOpts = {
config: SanitizedConfig;
auditLog?: AuditLogProtocol;
};
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
/**
* Returns Payload-backed DSR implementations pre-wired with config + auditLog.
*
* Wired by the app aggregator alongside feature binders. The returned binding
* is passed as a dependency to dsrRouter and any feature that needs DSR
* operations.
*/
export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
const auditLog = opts.auditLog ?? noopAuditLog;
return {
dataExport: new PayloadDataExport(opts.config, auditLog),
dataDelete: new PayloadDataDelete(opts.config, auditLog),
dataRectify: new PayloadDataRectify(opts.config, auditLog),
processingRestriction: new PayloadProcessingRestriction(
opts.config,
auditLog,
),
};
}

View File

@@ -0,0 +1,65 @@
import type { FieldPii } from "@repo/core-shared/payload";
export type { FieldPii };
/**
* DSR-specific Payload collection custom metadata.
*
* Collection authors annotate their collection configs with these shapes so the
* DSR implementations can walk `custom.subject` linkage and filter by each
* field's `custom.pii.exportable` flag.
*
* The `pii` map mirrors the field-level `FieldPii` shape from
* `@repo/core-shared/payload` so that the `pii-declaration-must-be-complete`
* ESLint rule is satisfied for each declared field.
*
* @example
* ```ts
* import type { DsrCollectionCustom } from "@repo/core-dsr";
*
* const ordersCollection: CollectionConfig = {
* slug: "orders",
* custom: {
* subject: { field: "userId", kind: "owner" },
* pii: {
* shippingAddress: {
* category: "contact-address",
* purpose: ["service-delivery"],
* exportable: true,
* restrictable: true,
* },
* },
* } satisfies DsrCollectionCustom,
* };
* ```
*/
export type DsrSubjectLinkKind = "self" | "owner" | "reference";
export type DsrSubjectLinkage = {
/**
* Field name in this collection that contains the subject's canonical ID.
* For "self" collections (e.g. "users"), use the literal "id".
*/
field: string;
/**
* - "self" — this collection IS the subject record (e.g. users).
* - "owner" — the subject owns these rows (e.g. orders, blog posts).
* - "reference" — the subject is referenced but does not own the row.
*/
kind: DsrSubjectLinkKind;
};
/** Shape expected in `collection.custom` for DSR-enabled collections. */
export type DsrCollectionCustom = {
/** Subject linkage metadata. Omit for collections with no subject data. */
subject?: DsrSubjectLinkage;
/**
* Map of field name → FieldPii metadata. Must include all required fields
* (`category`, `purpose`, `exportable`, `restrictable`) to satisfy the
* `pii-declaration-must-be-complete` ESLint conformance rule.
*
* Omit for collections with no PII fields.
*/
pii?: Record<string, FieldPii>;
};

View File

@@ -0,0 +1,75 @@
import type { AuditEntry } from "@repo/core-shared/audit";
export type DsrFormat = "json" | "json-ld";
export type DeletionMode = "soft" | "cascade-hard";
export type DeletionReason =
| "art-17-request"
| "admin-expunge"
| "retention-policy";
export type DeletionAction = "deleted" | "redacted" | "pseudonymized";
/** Row reference from a collection where the subject appears as a non-owner link. */
export type SubjectReference = {
rowId: string;
/** Field name in the collection that links to the subject. */
linkedField: string;
/** Slug of the collection containing the reference. */
linkedThrough: string;
};
/** Per-collection data bucket within a UserDataBundle. */
export type CollectionDataBucket = {
/** Rows directly owned by the subject (kind: "self" | "owner"). */
asSelf?: Array<Record<string, unknown>>;
/** Rows referencing the subject without owning the row (kind: "reference"). */
asReference?: SubjectReference[];
};
/**
* GDPR Art. 15/20 export payload.
*
* `data` is keyed by Payload collection slug. asSelf contains exportable-PII-
* filtered rows the subject owns; asReference lists row IDs + link coordinates
* for rows that merely reference the subject.
*/
export type UserDataBundle = {
subjectId: string;
/** ISO 8601 timestamp of when the export was produced. */
exportedAt: string;
format: DsrFormat;
data: Record<string, CollectionDataBucket>;
/** Audit entries scoped to this subject's activity. */
auditLog?: AuditEntry[];
/** JSON-LD @context URI or inline object; populated when format === "json-ld". */
"@context"?: string | Record<string, unknown>;
};
/** Per-collection summary of what the deletion touched. */
export type DeletionAffected = {
collection: string;
rowsAffected: number;
action: DeletionAction;
/** PII field names that were NULLed when action === "redacted". */
fields?: string[];
};
/**
* Immutable proof of a completed GDPR Art. 17 deletion / erasure request.
*
* The `auditEntryId` links back to the audit log entry created at deletion
* time, forming a tamper-evident chain for regulatory inspection.
*/
export type DeletionCertificate = {
/** Subject ID, or "erased-{hash}" if the ID itself was purged. */
subjectId: string;
mode: DeletionMode;
/** ISO 8601 timestamp of the deletion. */
timestamp: string;
reason: DeletionReason;
affected: DeletionAffected[];
/** ID of the audit log entry that recorded this operation. */
auditEntryId: string;
};

View File

@@ -0,0 +1,139 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import type { DsrBinding } from "./di/bind-production";
import { createExportHandler } from "./handlers/export-handler";
import { createDeleteHandler } from "./handlers/delete-handler";
import { createRectifyHandler } from "./handlers/rectify-handler";
import type { RectifyHandlerInput } from "./handlers/rectify-handler";
import { createRestrictHandler } from "./handlers/restrict-handler";
export type DsrTrpcUser = {
id?: string;
roles?: string[];
};
const requireAuthenticated = t.middleware(({ ctx, next }) => {
const user = (ctx as { user?: DsrTrpcUser }).user;
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({ ctx: { ...ctx, user } });
});
const dsrProcedure = t.procedure
.use(requireAuthenticated)
.use(defineErrorMiddleware([]));
/**
* 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`.
*
* @example
* ```ts
* const binding = bindProductionDsr({ config, auditLog });
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
* ```
*/
export function createDsrRouter(binding: DsrBinding) {
// Handlers are created lazily (inside procedure closures) so that the
// dsrRouter singleton proxy doesn't trigger at module init time.
return t.router({
export: dsrProcedure
.input(
z
.object({
subjectId: z.string().min(1),
format: z.enum(["json", "json-ld"]),
})
.strict(),
)
.query(async ({ input }) => {
const res = await createExportHandler(binding.dataExport)(input);
return res.body;
}),
delete: dsrProcedure
.input(
z
.object({
subjectId: z.string().min(1),
mode: z.enum(["soft", "cascade-hard"]),
})
.strict(),
)
.mutation(async ({ ctx, input }) => {
if (input.mode === "cascade-hard") {
const user = (ctx as { user: DsrTrpcUser }).user;
if (!user.roles?.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin role required for cascade-hard deletion",
});
}
}
const res = await createDeleteHandler(binding.dataDelete)(input);
return res.body;
}),
rectify: dsrProcedure
.input(
z
.object({
subjectId: z.string().min(1),
collection: z.string().min(1),
field: z.string().min(1),
value: z.unknown(),
})
.strict(),
)
.mutation(async ({ input }) => {
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
const res = await createRectifyHandler(binding.dataRectify)(
input as RectifyHandlerInput,
);
return res.body;
}),
restrict: dsrProcedure
.input(
z
.object({
subjectId: z.string().min(1),
granted: z.boolean(),
})
.strict(),
)
.mutation(async ({ input }) => {
const res = await createRestrictHandler(binding.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.
*/
export const dsrRouter = createDsrRouter(
new Proxy({} as DsrBinding, {
get(_target, prop) {
if (prop === "then") return undefined; // not a Promise
throw new Error(
`dsrRouter singleton used without providing a DsrBinding. ` +
`Use createDsrRouter(binding) instead.`,
);
},
}),
);
export type DsrRouter = ReturnType<typeof createDsrRouter>;

View File

@@ -0,0 +1,20 @@
import type { IDataDelete } from "../data-delete.interface";
import type { DeletionMode, DeletionCertificate } from "../dsr-types";
import type { HandlerResponse } from "./handler-types";
export type DeleteHandlerInput = {
subjectId: string;
mode: DeletionMode;
};
export function createDeleteHandler(dataDelete: IDataDelete) {
return async (
input: DeleteHandlerInput,
): Promise<HandlerResponse<DeletionCertificate>> => {
const cert = await dataDelete.deleteSubjectData(
input.subjectId,
input.mode,
);
return { status: 200, body: cert };
};
}

View File

@@ -0,0 +1,24 @@
import type { IDataExport } from "../data-export.interface";
import type { DsrFormat, UserDataBundle } from "../dsr-types";
import type { HandlerResponse } from "./handler-types";
export type ExportHandlerInput = {
subjectId: string;
format: DsrFormat;
};
export function createExportHandler(dataExport: IDataExport) {
return async (
input: ExportHandlerInput,
): Promise<HandlerResponse<UserDataBundle>> => {
const bundle = await dataExport.exportSubjectData(
input.subjectId,
input.format,
);
const headers: Record<string, string> =
input.format === "json-ld"
? { "Content-Type": "application/ld+json" }
: { "Content-Type": "application/json" };
return { status: 200, body: bundle, headers };
};
}

View File

@@ -0,0 +1,5 @@
export type HandlerResponse<T = unknown> = {
status: number;
body: T;
headers?: Record<string, string>;
};

View File

@@ -0,0 +1,23 @@
import type { IDataRectify } from "../data-rectify.interface";
import type { HandlerResponse } from "./handler-types";
export type RectifyHandlerInput = {
subjectId: string;
collection: string;
field: string;
value: unknown;
};
export function createRectifyHandler(dataRectify: IDataRectify) {
return async (
input: RectifyHandlerInput,
): Promise<HandlerResponse<{ ok: true }>> => {
await dataRectify.updateSubjectField(
input.subjectId,
input.collection,
input.field,
input.value,
);
return { status: 200, body: { ok: true as const } };
};
}

View File

@@ -0,0 +1,18 @@
import type { IProcessingRestriction } from "../processing-restriction.interface";
import type { HandlerResponse } from "./handler-types";
export type RestrictHandlerInput = {
subjectId: string;
granted: boolean;
};
export function createRestrictHandler(
processingRestriction: IProcessingRestriction,
) {
return async (
input: RestrictHandlerInput,
): Promise<HandlerResponse<{ ok: true }>> => {
await processingRestriction.setRestriction(input.subjectId, input.granted);
return { status: 200, body: { ok: true as const } };
};
}

View File

@@ -0,0 +1,27 @@
import { randomUUID } from "node:crypto";
import type { IDataDelete } from "./data-delete.interface";
import type { DeletionMode, DeletionCertificate } from "./dsr-types";
/**
* Volatile in-memory IDataDelete. Returns a well-shaped DeletionCertificate
* without touching any persistence layer.
*
* Used in dev-seed and storybook contexts where Payload is unavailable.
* Not a recording double — use RecordingDataDelete from @repo/core-testing for
* call-assertion in unit tests.
*/
export class InMemoryDataDelete implements IDataDelete {
async deleteSubjectData(
subjectId: string,
mode: DeletionMode,
): Promise<DeletionCertificate> {
return {
subjectId,
mode,
timestamp: new Date().toISOString(),
reason: "art-17-request",
affected: [],
auditEntryId: randomUUID(),
};
}
}

View File

@@ -0,0 +1,24 @@
import type { IDataExport } from "./data-export.interface";
import type { DsrFormat, UserDataBundle } from "./dsr-types";
/**
* Volatile in-memory IDataExport. Returns an empty bundle with the correct
* shape.
*
* Used in dev-seed and storybook contexts where Payload is unavailable.
* Not a recording double — use RecordingDataExport from @repo/core-testing for
* call-assertion in unit tests.
*/
export class InMemoryDataExport implements IDataExport {
async exportSubjectData(
subjectId: string,
format: DsrFormat,
): Promise<UserDataBundle> {
return {
subjectId,
exportedAt: new Date().toISOString(),
format,
data: {},
};
}
}

View File

@@ -0,0 +1,19 @@
import type { IDataRectify } from "./data-rectify.interface";
/**
* Volatile in-memory IDataRectify. Accepts calls but makes no changes.
*
* Used in dev-seed and storybook contexts where Payload is unavailable.
* Not a recording double — use RecordingDataRectify from @repo/core-testing for
* call-assertion in unit tests.
*/
export class InMemoryDataRectify implements IDataRectify {
async updateSubjectField(
_subjectId: string,
_collection: string,
_field: string,
_value: unknown,
): Promise<void> {
// no-op in dev-seed
}
}

View File

@@ -0,0 +1,25 @@
import type { IProcessingRestriction } from "./processing-restriction.interface";
/**
* Volatile in-memory IProcessingRestriction. Tracks restriction state in
* memory; state is lost on process restart.
*
* Used in dev-seed and storybook contexts where Payload is unavailable.
* Not a recording double — use RecordingProcessingRestriction from
* @repo/core-testing for call-assertion in unit tests.
*/
export class InMemoryProcessingRestriction implements IProcessingRestriction {
private readonly restricted = new Set<string>();
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
if (granted) {
this.restricted.add(subjectId);
} else {
this.restricted.delete(subjectId);
}
}
async isRestricted(subjectId: string): Promise<boolean> {
return this.restricted.has(subjectId);
}
}

View File

@@ -0,0 +1,50 @@
export type {
DsrFormat,
DeletionMode,
DeletionReason,
DeletionAction,
SubjectReference,
CollectionDataBucket,
UserDataBundle,
DeletionAffected,
DeletionCertificate,
} from "./dsr-types";
export type { IDataExport } from "./data-export.interface";
export type { IDataDelete } from "./data-delete.interface";
export type { IDataRectify } from "./data-rectify.interface";
export type { IProcessingRestriction } from "./processing-restriction.interface";
export type {
DsrSubjectLinkKind,
DsrSubjectLinkage,
DsrCollectionCustom,
FieldPii,
} from "./dsr-collection-custom";
export { PayloadDataExport } from "./payload-data-export";
export { PayloadDataDelete } from "./payload-data-delete";
export { PayloadDataRectify } from "./payload-data-rectify";
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
export { InMemoryDataExport } from "./in-memory-data-export";
export { InMemoryDataDelete } from "./in-memory-data-delete";
export { InMemoryDataRectify } from "./in-memory-data-rectify";
export { InMemoryProcessingRestriction } from "./in-memory-processing-restriction";
export { bindProductionDsr } from "./di/bind-production";
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 { HandlerResponse } from "./handlers/handler-types";
export { createExportHandler } from "./handlers/export-handler";
export type { ExportHandlerInput } from "./handlers/export-handler";
export { createDeleteHandler } from "./handlers/delete-handler";
export type { DeleteHandlerInput } from "./handlers/delete-handler";
export { createRectifyHandler } from "./handlers/rectify-handler";
export type { RectifyHandlerInput } from "./handlers/rectify-handler";
export { createRestrictHandler } from "./handlers/restrict-handler";
export type { RestrictHandlerInput } from "./handlers/restrict-handler";

View File

@@ -0,0 +1,307 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import { randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { IDataDelete } from "./data-delete.interface";
import type {
DeletionMode,
DeletionCertificate,
DeletionAffected,
} from "./dsr-types";
import type { DsrCollectionCustom } from "./dsr-collection-custom";
type PayloadDoc = Record<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<PayloadDoc>;
delete(args: {
collection: string;
id: string;
overrideAccess: true;
}): Promise<PayloadDoc>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
return field === "id"
? { id: { equals: subjectId } }
: { [field]: { equals: subjectId } };
}
async function softRedactOwnerRows(
payload: PayloadAPI,
slug: string,
docs: PayloadDoc[],
exportableFields: string[],
extraData: Record<string, unknown> = {},
): Promise<void> {
const hasContent =
exportableFields.length > 0 || Object.keys(extraData).length > 0;
if (!hasContent) return;
const nullData = Object.fromEntries(exportableFields.map((f) => [f, null]));
const data = { ...nullData, ...extraData };
for (const doc of docs) {
await payload.update({
collection: slug,
id: String(doc["id"]),
data,
overrideAccess: true,
});
}
}
async function cascadeHardDeleteOwnerRows(
payload: PayloadAPI,
slug: string,
docs: PayloadDoc[],
): Promise<void> {
for (const doc of docs) {
await payload.delete({
collection: slug,
id: String(doc["id"]),
overrideAccess: true,
});
}
}
async function redactReferenceField(
payload: PayloadAPI,
slug: string,
field: string,
docs: PayloadDoc[],
): Promise<void> {
for (const doc of docs) {
await payload.update({
collection: slug,
id: String(doc["id"]),
data: { [field]: null },
overrideAccess: true,
});
}
}
/**
* Payload-backed IDataDelete. Implements both GDPR Art. 17 deletion modes:
*
* - "soft" — restricts processing, NULLs exportable PII, redacts reference
* links. Row structure preserved (other subjects' data in shared rows intact).
* - "cascade-hard" — hard-deletes self/owner rows and redacts reference fields.
* Admin-only; auth enforcement at the tRPC procedure layer.
*
* Emits RESTRICT or DELETE audit entries per affected collection.
*/
export class PayloadDataDelete implements IDataDelete {
constructor(
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
) {}
async deleteSubjectData(
subjectId: string,
mode: DeletionMode,
): Promise<DeletionCertificate> {
const payload = await this.getPayloadFn({ config: this.config });
const correlationId = randomUUID();
const timestamp = new Date().toISOString();
const affected: DeletionAffected[] = [];
for (const collection of this.config.collections) {
const custom = (collection.custom ?? {}) as DsrCollectionCustom;
if (!custom.subject) continue;
const { field, kind } = custom.subject;
const result = await payload.find({
collection: collection.slug,
where: buildWhere(field, subjectId),
overrideAccess: true,
limit: 1000,
});
if (result.docs.length === 0) continue;
if (kind === "self" || kind === "owner") {
await this.processOwnerRows(
payload,
collection.slug,
mode,
custom,
result.docs,
subjectId,
correlationId,
affected,
);
} else {
await this.processReferenceRows(
payload,
collection.slug,
field,
mode,
result.docs,
subjectId,
correlationId,
affected,
);
}
}
return this.buildCertificate(
subjectId,
mode,
timestamp,
correlationId,
affected,
);
}
private async processOwnerRows(
payload: PayloadAPI,
slug: string,
mode: DeletionMode,
custom: DsrCollectionCustom,
docs: PayloadDoc[],
subjectId: string,
correlationId: string,
affected: DeletionAffected[],
): Promise<void> {
const piiMeta = custom.pii ?? {};
const exportableFields = Object.entries(piiMeta)
.filter(([, m]) => m.exportable)
.map(([name]) => name);
if (mode === "soft") {
const kind = custom.subject?.kind;
const extraData: Record<string, unknown> =
kind === "self"
? { processingRestrictedAt: new Date().toISOString() }
: {};
await softRedactOwnerRows(
payload,
slug,
docs,
exportableFields,
extraData,
);
affected.push({
collection: slug,
rowsAffected: docs.length,
action: "redacted",
fields: exportableFields,
});
await this.recordAudit(
subjectId,
"RESTRICT",
slug,
exportableFields,
correlationId,
"art-17-request",
);
} else {
await cascadeHardDeleteOwnerRows(payload, slug, docs);
affected.push({
collection: slug,
rowsAffected: docs.length,
action: "deleted",
});
await this.recordAudit(
subjectId,
"DELETE",
slug,
[],
correlationId,
"art-17-request",
);
}
}
private async processReferenceRows(
payload: PayloadAPI,
slug: string,
field: string,
mode: DeletionMode,
docs: PayloadDoc[],
subjectId: string,
correlationId: string,
affected: DeletionAffected[],
): Promise<void> {
await redactReferenceField(payload, slug, field, docs);
affected.push({
collection: slug,
rowsAffected: docs.length,
action: "redacted",
fields: [field],
});
const action = mode === "soft" ? "RESTRICT" : "DELETE";
await this.recordAudit(
subjectId,
action,
slug,
[field],
correlationId,
"art-17-request",
);
}
private async recordAudit(
subjectId: string,
action: "RESTRICT" | "DELETE" | "UNRESTRICT",
resourceType: string,
changedFields: string[],
correlationId: string,
reason: string,
): Promise<void> {
await this.auditLog.record({
actorId: subjectId,
actorType: "user",
actorRoles: [],
action,
resource: { type: resourceType },
changedFields: changedFields.length > 0 ? changedFields : undefined,
at: new Date(),
scope: {
feature: "core-dsr",
environment: process.env["NODE_ENV"] ?? "development",
tenant: "default",
},
reason,
correlationId,
from: { ipTruncated: "system", userAgent: "system" },
containsPii: false,
outcome: "success",
});
}
private buildCertificate(
subjectId: string,
mode: DeletionMode,
timestamp: string,
correlationId: string,
affected: DeletionAffected[],
): DeletionCertificate {
const certSubjectId =
mode === "cascade-hard"
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
: subjectId;
return {
subjectId: certSubjectId,
mode,
timestamp,
reason: "art-17-request",
affected,
auditEntryId: correlationId,
};
}
}

View File

@@ -0,0 +1,145 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { IDataExport } from "./data-export.interface";
import type {
DsrFormat,
UserDataBundle,
CollectionDataBucket,
SubjectReference,
} from "./dsr-types";
import type { DsrCollectionCustom } from "./dsr-collection-custom";
// Mirrors packages/core-dsr/src/contexts/user-data.jsonld — update both together.
const USER_DATA_JSONLD_CONTEXT: Record<string, unknown> = {
"@vocab": "https://schema.org/",
dsr: "https://w3.org/ns/dpv#",
prov: "https://www.w3.org/ns/prov#",
subjectId: "identifier",
exportedAt: "dateCreated",
format: "encodingFormat",
data: { "@id": "prov:hadMember", "@container": "@index" },
asSelf: { "@id": "dsr:hasPersonalDataHandling", "@type": "@id" },
asReference: { "@id": "dsr:hasDataSubjectRight", "@type": "@id" },
rowId: "identifier",
linkedField: "name",
linkedThrough: { "@id": "isPartOf", "@type": "@id" },
auditLog: { "@id": "prov:wasGeneratedBy", "@type": "@id" },
UserDataBundle: "dsr:RightOfAccess",
SubjectReference: "dsr:DataSubjectRight",
};
type PayloadDoc = Record<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* Payload-backed IDataExport. Walks all collections annotated with
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
* and filters to fields marked `exportable: true` in `custom.pii`.
*
* Emits an EXPORT audit entry for every call. The `getPayloadFn` parameter is
* injectable for unit tests; production code omits it.
*/
export class PayloadDataExport implements IDataExport {
constructor(
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
) {}
async exportSubjectData(
subjectId: string,
format: DsrFormat,
): Promise<UserDataBundle> {
const payload = await this.getPayloadFn({ config: this.config });
const data: Record<string, CollectionDataBucket> = {};
for (const collection of this.config.collections) {
const custom = (collection.custom ?? {}) as DsrCollectionCustom;
if (!custom.subject) continue;
const { field, kind } = custom.subject;
const where =
field === "id"
? { id: { equals: subjectId } }
: { [field]: { equals: subjectId } };
const result = await payload.find({
collection: collection.slug,
where,
overrideAccess: true,
limit: 1000,
});
if (result.docs.length === 0) continue;
if (kind === "self" || kind === "owner") {
const piiMeta = custom.pii ?? {};
const exportableFields = Object.entries(piiMeta)
.filter(([, m]) => m.exportable)
.map(([name]) => name);
data[collection.slug] = {
asSelf: result.docs.map((doc) => {
const row: PayloadDoc = { id: doc["id"] };
for (const f of exportableFields) {
if (f in doc) row[f] = doc[f];
}
return row;
}),
};
} else {
// reference kind — expose only linking coordinates, not row content
data[collection.slug] = {
asReference: result.docs.map(
(doc): SubjectReference => ({
rowId: String(doc["id"]),
linkedField: field,
linkedThrough: collection.slug,
}),
),
};
}
}
await this.auditLog.record({
actorId: subjectId,
actorType: "user",
actorRoles: [],
action: "EXPORT",
resource: { type: "subject-data" },
at: new Date(),
scope: {
feature: "core-dsr",
environment: process.env["NODE_ENV"] ?? "development",
tenant: "default",
},
from: { ipTruncated: "system", userAgent: "system" },
containsPii: false,
outcome: "success",
});
const bundle: UserDataBundle = {
subjectId,
exportedAt: new Date().toISOString(),
format,
data,
};
if (format === "json-ld") {
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
}
return bundle;
}
}

View File

@@ -0,0 +1,105 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { IDataRectify } from "./data-rectify.interface";
import type { DsrCollectionCustom } from "./dsr-collection-custom";
type PayloadDoc = Record<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<PayloadDoc>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* Payload-backed IDataRectify. Verifies the target field is PII-tagged before
* updating and emits a RESTRICT audit entry with `reason: "art-16-request"` as
* the tamper-evident record of the Art. 16 correction.
*/
export class PayloadDataRectify implements IDataRectify {
constructor(
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
) {}
async updateSubjectField(
subjectId: string,
collectionSlug: string,
field: string,
value: unknown,
): Promise<void> {
const collectionCfg = this.config.collections.find(
(c) => c.slug === collectionSlug,
);
if (!collectionCfg) {
throw new Error(`Collection "${collectionSlug}" not found in config`);
}
const custom = (collectionCfg.custom ?? {}) as DsrCollectionCustom;
if (!custom.subject) {
throw new Error(
`Collection "${collectionSlug}" has no DSR subject linkage`,
);
}
if (!custom.pii?.[field]) {
throw new Error(
`Field "${field}" in "${collectionSlug}" is not tagged as PII`,
);
}
const { field: subjectField } = custom.subject;
const where =
subjectField === "id"
? { id: { equals: subjectId } }
: { [subjectField]: { equals: subjectId } };
const payload = await this.getPayloadFn({ config: this.config });
const result = await payload.find({
collection: collectionSlug,
where,
overrideAccess: true,
limit: 1000,
});
for (const doc of result.docs) {
await payload.update({
collection: collectionSlug,
id: String(doc["id"]),
data: { [field]: value },
overrideAccess: true,
});
}
await this.auditLog.record({
actorId: subjectId,
actorType: "user",
actorRoles: [],
action: "RESTRICT",
resource: { type: collectionSlug },
changedFields: [field],
at: new Date(),
scope: {
feature: "core-dsr",
environment: process.env["NODE_ENV"] ?? "development",
tenant: "default",
},
reason: "art-16-request",
from: { ipTruncated: "system", userAgent: "system" },
containsPii: false,
outcome: "success",
});
}
}

View File

@@ -0,0 +1,87 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import type { IProcessingRestriction } from "./processing-restriction.interface";
type PayloadDoc = Record<string, unknown>;
type PayloadAPI = {
find(args: {
collection: string;
where: Record<string, unknown>;
overrideAccess: true;
limit: number;
}): Promise<{ docs: PayloadDoc[] }>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<PayloadDoc>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
const USERS_COLLECTION = "users";
const RESTRICTION_FIELD = "processingRestrictedAt";
/**
* Payload-backed IProcessingRestriction. Toggles and reads the
* `processingRestrictedAt` date field on the subject's user record.
*
* Emits RESTRICT or UNRESTRICT audit entries on every state change.
* The `subjectId` is the document ID in the `users` collection.
*/
export class PayloadProcessingRestriction implements IProcessingRestriction {
constructor(
private readonly config: SanitizedConfig,
private readonly auditLog: AuditLogProtocol,
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
) {}
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
const payload = await this.getPayloadFn({ config: this.config });
await payload.update({
collection: USERS_COLLECTION,
id: subjectId,
data: { [RESTRICTION_FIELD]: granted ? new Date().toISOString() : null },
overrideAccess: true,
});
await this.auditLog.record({
actorId: subjectId,
actorType: "user",
actorRoles: [],
action: granted ? "RESTRICT" : "UNRESTRICT",
resource: { type: USERS_COLLECTION, id: subjectId },
changedFields: [RESTRICTION_FIELD],
at: new Date(),
scope: {
feature: "core-dsr",
environment: process.env["NODE_ENV"] ?? "development",
tenant: "default",
},
from: { ipTruncated: "system", userAgent: "system" },
containsPii: false,
outcome: "success",
});
}
async isRestricted(subjectId: string): Promise<boolean> {
const payload = await this.getPayloadFn({ config: this.config });
const result = await payload.find({
collection: USERS_COLLECTION,
where: { id: { equals: subjectId } },
overrideAccess: true,
limit: 1,
});
const doc = result.docs[0];
if (!doc) return false;
const restrictedAt = doc[RESTRICTION_FIELD];
return restrictedAt !== null && restrictedAt !== undefined;
}
}

View File

@@ -0,0 +1,25 @@
/**
* GDPR Art. 18 (right to restriction of processing).
*
* Toggles and reads the `processingRestrictedAt` flag on the subject's user
* record. When restricted, downstream use cases should call `isRestricted`
* before processing personal data and short-circuit if true.
*
* Emits RESTRICT / UNRESTRICT audit entries on every state change.
*/
export interface IProcessingRestriction {
/**
* Grant or revoke processing restriction for the given subject.
*
* @param subjectId - The subject's canonical ID.
* @param granted - true to restrict processing; false to lift restriction.
*/
setRestriction(subjectId: string, granted: boolean): Promise<void>;
/**
* Return whether processing is currently restricted for the given subject.
*
* @param subjectId - The subject's canonical ID.
*/
isRestricted(subjectId: string): Promise<boolean>;
}