feat(core-dsr): Payload impls, recording doubles, DI binders, contract tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:05:06 +00:00
parent 8068d1bf98
commit 6606b59d1e
35 changed files with 2375 additions and 24 deletions

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