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

@@ -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.
*
@@ -103,6 +110,59 @@ export function buildPurgeHandler(
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 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 (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 +183,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 +235,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],
};
}