feat(core-shared): add PII and retention type primitives

Introduces PiiCategory, DataProcessingPurpose, RetentionTrigger,
RetentionAction, FieldPii, FieldRetention, PAYLOAD_AUTH_PII_DEFAULTS,
PurgeSchedule, and CollectionRetention in core-shared/payload/.
Augments payload's FieldCustom and CollectionCustom interfaces via
ambient declaration so downstream collection configs gain typed
custom.pii and custom.retention / custom.authPii fields.

Credential fields (password, salt, hash, resetPasswordToken,
resetPasswordExpiration, loginAttempts, lockUntil, apiKey, apiKeyIndex)
are null in PAYLOAD_AUTH_PII_DEFAULTS to exclude security material
from DPA mapping. Adds @vitest/coverage-v8 and coverage exclusions
for boilerplate infrastructure files so coverage:diff is gated on
new executable code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:23:24 +00:00
parent c298f396b1
commit a94e8032b5
9 changed files with 296 additions and 12 deletions

View File

@@ -4,3 +4,13 @@ export { seoFields } from "./fields/seo-fields";
export { cta } from "./blocks/cta";
export { setPublishedAt } from "./hooks/set-published-at";
export { slugifyIfMissing } from "./hooks/slugify-if-missing";
export type {
PiiCategory,
DataProcessingPurpose,
RetentionTrigger,
RetentionAction,
FieldRetention,
FieldPii,
} from "./pii-types";
export { PAYLOAD_AUTH_PII_DEFAULTS } from "./pii-types";
export type { PurgeSchedule, CollectionRetention } from "./retention-types";

View File

@@ -0,0 +1,16 @@
import type { FieldPii } from "./pii-types";
import type { CollectionRetention } from "./retention-types";
declare module "payload" {
// FieldBase.custom is typed as FieldCustom (interface extending Record<string, any>).
// Augmenting it makes pii available on every field type.
interface FieldCustom {
pii?: FieldPii;
}
// CollectionConfig.custom is typed as CollectionCustom (interface extending Record<string, any>).
interface CollectionCustom {
retention?: CollectionRetention;
authPii?: Record<string, FieldPii | null>;
}
}

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { PAYLOAD_AUTH_PII_DEFAULTS, type FieldPii } from "./pii-types";
const CREDENTIAL_FIELDS = [
"password",
"salt",
"hash",
"resetPasswordToken",
"resetPasswordExpiration",
"loginAttempts",
"lockUntil",
"apiKey",
"apiKeyIndex",
] as const;
describe("FieldPii type safety", () => {
it("rejects FieldPii missing required fields at compile time", () => {
// @ts-expect-error — 'purpose', 'exportable', 'restrictable' are required
const _missingRequired: FieldPii = { category: "contact-email" };
void _missingRequired;
});
it("rejects FieldPii missing exportable at compile time", () => {
// @ts-expect-error — 'exportable' is required
const _missingExportable: FieldPii = {
category: "contact-email",
purpose: ["account-authentication"],
restrictable: true,
};
void _missingExportable;
});
});
describe("FieldPii valid shapes", () => {
it("accepts a minimal valid FieldPii", () => {
const valid: FieldPii = {
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: false,
};
expect(valid.category).toBe("contact-email");
expect(valid.retention).toBeUndefined();
});
it("accepts FieldPii with optional retention", () => {
const withRetention: FieldPii = {
category: "network-ip",
purpose: ["analytics-aggregation"],
exportable: false,
restrictable: false,
retention: {
duration: "P30D",
trigger: "from-creation",
action: "hard-delete",
},
};
expect(withRetention.retention?.duration).toBe("P30D");
expect(withRetention.retention?.trigger).toBe("from-creation");
expect(withRetention.retention?.action).toBe("hard-delete");
});
it("accepts a custom PiiCategory string via extension escape hatch", () => {
const extended: FieldPii = {
category: "custom-biometric-data",
purpose: ["legal-compliance"],
exportable: false,
restrictable: true,
};
expect(extended.category).toBe("custom-biometric-data");
});
});
describe("PAYLOAD_AUTH_PII_DEFAULTS", () => {
it("sets all credential fields to null", () => {
for (const field of CREDENTIAL_FIELDS) {
expect(PAYLOAD_AUTH_PII_DEFAULTS[field]).toBeNull();
}
});
it("maps email to a non-null FieldPii with correct shape", () => {
const emailPii = PAYLOAD_AUTH_PII_DEFAULTS["email"];
expect(emailPii).not.toBeNull();
expect(emailPii?.category).toBe("contact-email");
expect(emailPii?.purpose).toContain("account-authentication");
expect(emailPii?.purpose).toContain("transactional-notifications");
expect(emailPii?.exportable).toBe(true);
expect(emailPii?.restrictable).toBe(true);
});
it("has exactly 10 keys: email plus 9 credential fields", () => {
expect(Object.keys(PAYLOAD_AUTH_PII_DEFAULTS)).toHaveLength(10);
});
it("email has no retention override (falls back to collection-level)", () => {
const emailPii = PAYLOAD_AUTH_PII_DEFAULTS["email"];
expect(emailPii?.retention).toBeUndefined();
});
});

View File

@@ -0,0 +1,64 @@
export type PiiCategory =
| "contact-email"
| "contact-phone"
| "contact-address"
| "identification-name"
| "identification-username"
| "identification-government-id"
| "auth-credential"
| "auth-token"
| "network-ip"
| "network-user-agent"
| "financial-info"
| "behavioral-engagement"
| "document-content"
| "derived-metric"
| (string & Record<never, never>);
export type DataProcessingPurpose =
| "account-authentication"
| "transactional-notifications"
| "marketing-communications"
| "analytics-aggregation"
| "legal-compliance"
| "service-delivery"
| (string & Record<never, never>);
export type RetentionTrigger =
| "from-creation"
| "from-last-access"
| "after-deletion";
export type RetentionAction = "hard-delete" | "pseudonymize";
export type FieldRetention = {
duration: string;
trigger: RetentionTrigger;
action: RetentionAction;
};
export type FieldPii = {
category: PiiCategory;
purpose: DataProcessingPurpose[];
retention?: FieldRetention;
exportable: boolean;
restrictable: boolean;
};
export const PAYLOAD_AUTH_PII_DEFAULTS: Record<string, FieldPii | null> = {
email: {
category: "contact-email",
purpose: ["account-authentication", "transactional-notifications"],
exportable: true,
restrictable: true,
},
password: null,
salt: null,
hash: null,
resetPasswordToken: null,
resetPasswordExpiration: null,
loginAttempts: null,
lockUntil: null,
apiKey: null,
apiKeyIndex: null,
};

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import type { CollectionRetention, PurgeSchedule } from "./retention-types";
describe("CollectionRetention type safety", () => {
it("accepts a minimal CollectionRetention with only purgeSchedule", () => {
const minimal: CollectionRetention = { purgeSchedule: "daily" };
expect(minimal.purgeSchedule).toBe("daily");
expect(minimal.activeRetention).toBeUndefined();
expect(minimal.postDeletion).toBeUndefined();
expect(minimal.coldArchive).toBeUndefined();
});
it("accepts a full CollectionRetention", () => {
const full: CollectionRetention = {
purgeSchedule: "weekly",
activeRetention: { duration: "P2Y", trigger: "from-last-access" },
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "pseudonymize",
},
coldArchive: { duration: "P7Y", trigger: "from-creation" },
};
expect(full.activeRetention?.duration).toBe("P2Y");
expect(full.postDeletion?.action).toBe("pseudonymize");
expect(full.coldArchive?.duration).toBe("P7Y");
});
it("accepts a cron expression as PurgeSchedule via string extension", () => {
const schedule: PurgeSchedule = "0 3 * * 0";
expect(schedule).toBe("0 3 * * 0");
});
it("rejects CollectionRetention missing required purgeSchedule at compile time", () => {
// @ts-expect-error — 'purgeSchedule' is required
const _missing: CollectionRetention = {
activeRetention: { duration: "P1Y", trigger: "from-creation" },
};
void _missing;
});
});

View File

@@ -0,0 +1,21 @@
import type { RetentionAction } from "./pii-types";
export type PurgeSchedule =
| "daily"
| "weekly"
| "monthly"
| (string & Record<never, never>);
export type CollectionRetention = {
activeRetention?: {
duration: string;
trigger: "from-creation" | "from-last-access";
};
postDeletion?: {
duration: string;
trigger: "after-deletion";
action: RetentionAction;
};
purgeSchedule: PurgeSchedule;
coldArchive?: { duration: string; trigger: "from-creation" };
};