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

@@ -4,9 +4,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// without a pseudonym salt (by design). Provide one for the whole suite.
process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production";
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
// Hoisted so the payload mock and assertions share the same jobs.queue spy.
const { jobsQueueMock } = vi.hoisted(() => ({
jobsQueueMock: vi.fn(async () => ({ id: "job-1" })),
}));
vi.mock("@repo/core-cms", () => ({
default: Promise.resolve({
collections: [
{
slug: "users",
custom: {
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
fields: [],
},
{ slug: "pages", fields: [] },
],
}),
}));
vi.mock("payload", () => ({
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
getPayload: vi.fn(async () => ({ jobs: { queue: jobsQueueMock } })),
}));
vi.mock("@repo/blog/di/bind-production", () => ({
bindProductionBlog: vi.fn(),
@@ -73,6 +98,17 @@ describe("bindAllProduction", () => {
expect(bindProductionMedia).toHaveBeenCalledOnce();
});
it("registers retention purge jobs at production boot (A3)", async () => {
const { bindAllProduction } = await import("./bind-production");
await bindAllProduction();
// one enqueue per collection declaring custom.retention.purgeSchedule
expect(jobsQueueMock).toHaveBeenCalledTimes(1);
expect(jobsQueueMock).toHaveBeenCalledWith(
expect.objectContaining({ task: "retention-purge--users" }),
);
});
it("is idempotent via bindAll — second call does not re-bind", async () => {
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");

View File

@@ -21,6 +21,10 @@ import {
NoopRateLimit,
type RateLimitBudget,
} from "@repo/core-shared/rate-limit";
import {
registerRetentionPurgeJobs,
type GetPayloadFn,
} from "@repo/core-shared/payload";
import { bindAudit, type IAuditLog } from "@repo/core-audit";
import {
bindProductionConsent,
@@ -192,6 +196,17 @@ export async function bindAllProduction(): Promise<void> {
bindProductionMarketingPages(ctx);
bindProductionNavigation(ctx);
bindProductionMedia(ctx);
// Kick off the retention purge cycle (audit finding A3): enqueue the first
// `retention-purge--<slug>` job for every collection declaring a
// custom.retention.purgeSchedule. The task definitions live in the Payload
// config (core-cms jobs.tasks); each run re-enqueues the next cycle.
await registerRetentionPurgeJobs({
queue,
config: resolvedConfig,
getPayload: getPayload as unknown as GetPayloadFn,
auditLog,
});
}
/**