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>
This commit is contained in:
2026-07-10 18:02:43 +02:00
parent d09b3e2cdd
commit 413ac0273c
15 changed files with 755 additions and 54 deletions

View File

@@ -97,6 +97,101 @@ 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("owner role: does NOT set processingRestrictedAt", async () => {
const config = makeMockConfig([
{