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:
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-18T15:54:10.661Z",
|
"generatedAt": "2026-05-18T18:22:54.558Z",
|
||||||
"commit": "065ca1b",
|
"commit": "c298f39",
|
||||||
"repo": {
|
"repo": {
|
||||||
"statements": 95.92,
|
"statements": 96.34,
|
||||||
"branches": 89.18,
|
"branches": 91.4,
|
||||||
"functions": 100,
|
"functions": 96.75,
|
||||||
"lines": 95.92,
|
"lines": 96.34,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 3165,
|
"lf": 4100,
|
||||||
"lh": 3036,
|
"lh": 3950,
|
||||||
"brf": 499,
|
"brf": 767,
|
||||||
"brh": 445,
|
"brh": 701,
|
||||||
"fnf": 152,
|
"fnf": 246,
|
||||||
"fnh": 152
|
"fnh": 238
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"byPackage": {
|
"byPackage": {
|
||||||
@@ -58,6 +58,20 @@
|
|||||||
"fnh": 10
|
"fnh": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@repo/core-shared": {
|
||||||
|
"statements": 97.75,
|
||||||
|
"branches": 95.52,
|
||||||
|
"functions": 91.49,
|
||||||
|
"lines": 97.75,
|
||||||
|
"counts": {
|
||||||
|
"lf": 935,
|
||||||
|
"lh": 914,
|
||||||
|
"brf": 268,
|
||||||
|
"brh": 256,
|
||||||
|
"fnf": 94,
|
||||||
|
"fnh": 86
|
||||||
|
}
|
||||||
|
},
|
||||||
"@repo/marketing-pages": {
|
"@repo/marketing-pages": {
|
||||||
"statements": 95.64,
|
"statements": 95.64,
|
||||||
"branches": 83.93,
|
"branches": 83.93,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"@sentry/node": "^10.51.0",
|
"@sentry/node": "^10.51.0",
|
||||||
"@sentry/react": "^10.51.0",
|
"@sentry/react": "^10.51.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"inversify": "^6.2.0",
|
"inversify": "^6.2.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.1.0"
|
||||||
|
|||||||
@@ -4,3 +4,13 @@ export { seoFields } from "./fields/seo-fields";
|
|||||||
export { cta } from "./blocks/cta";
|
export { cta } from "./blocks/cta";
|
||||||
export { setPublishedAt } from "./hooks/set-published-at";
|
export { setPublishedAt } from "./hooks/set-published-at";
|
||||||
export { slugifyIfMissing } from "./hooks/slugify-if-missing";
|
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";
|
||||||
|
|||||||
16
packages/core-shared/src/payload/payload-custom-ambient.d.ts
vendored
Normal file
16
packages/core-shared/src/payload/payload-custom-ambient.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/core-shared/src/payload/pii-types.test.ts
Normal file
99
packages/core-shared/src/payload/pii-types.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
packages/core-shared/src/payload/pii-types.ts
Normal file
64
packages/core-shared/src/payload/pii-types.ts
Normal 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,
|
||||||
|
};
|
||||||
41
packages/core-shared/src/payload/retention-types.test.ts
Normal file
41
packages/core-shared/src/payload/retention-types.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/core-shared/src/payload/retention-types.ts
Normal file
21
packages/core-shared/src/payload/retention-types.ts
Normal 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" };
|
||||||
|
};
|
||||||
@@ -3,6 +3,24 @@ import { mergeConfig } from "vitest/config";
|
|||||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||||
|
|
||||||
export default mergeConfig(nodeVitestConfig, {
|
export default mergeConfig(nodeVitestConfig, {
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
exclude: [
|
||||||
|
// Ambient declaration files have no runtime code
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
// Pure TypeScript interface files — erased at runtime, not unit-testable
|
||||||
|
"src/**/*.interface.ts",
|
||||||
|
// DI symbol constants — boilerplate, covered implicitly by bind-* tests
|
||||||
|
"src/**/symbols.ts",
|
||||||
|
// tRPC context factory — wired at app bootstrap, not unit-testable
|
||||||
|
"src/trpc/context.ts",
|
||||||
|
// Sentry client init — browser/node SDK init, tested in apps
|
||||||
|
"src/instrumentation/sentry/**",
|
||||||
|
// Pure type-alias file — no executable code
|
||||||
|
"src/payload/retention-types.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
alias: { "@": path.resolve(__dirname, "./src") },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user