From a94e8032b52720bd9d21085798af7205a28d2a53 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 18 May 2026 18:23:24 +0000 Subject: [PATCH] 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 --- coverage/summary.json | 38 ++++--- packages/core-shared/package.json | 1 + packages/core-shared/src/payload/index.ts | 10 ++ .../src/payload/payload-custom-ambient.d.ts | 16 +++ .../core-shared/src/payload/pii-types.test.ts | 99 +++++++++++++++++++ packages/core-shared/src/payload/pii-types.ts | 64 ++++++++++++ .../src/payload/retention-types.test.ts | 41 ++++++++ .../src/payload/retention-types.ts | 21 ++++ packages/core-shared/vitest.config.ts | 18 ++++ 9 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 packages/core-shared/src/payload/payload-custom-ambient.d.ts create mode 100644 packages/core-shared/src/payload/pii-types.test.ts create mode 100644 packages/core-shared/src/payload/pii-types.ts create mode 100644 packages/core-shared/src/payload/retention-types.test.ts create mode 100644 packages/core-shared/src/payload/retention-types.ts diff --git a/coverage/summary.json b/coverage/summary.json index 22f9a4f..51c20c5 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-18T15:54:10.661Z", - "commit": "065ca1b", + "generatedAt": "2026-05-18T18:22:54.558Z", + "commit": "c298f39", "repo": { - "statements": 95.92, - "branches": 89.18, - "functions": 100, - "lines": 95.92, + "statements": 96.34, + "branches": 91.4, + "functions": 96.75, + "lines": 96.34, "counts": { - "lf": 3165, - "lh": 3036, - "brf": 499, - "brh": 445, - "fnf": 152, - "fnh": 152 + "lf": 4100, + "lh": 3950, + "brf": 767, + "brh": 701, + "fnf": 246, + "fnh": 238 } }, "byPackage": { @@ -58,6 +58,20 @@ "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": { "statements": 95.64, "branches": 83.93, diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 9b4c5e9..14e5649 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -68,6 +68,7 @@ "@sentry/node": "^10.51.0", "@sentry/react": "^10.51.0", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", "inversify": "^6.2.0", "reflect-metadata": "^0.2.2", "vitest": "^3.1.0" diff --git a/packages/core-shared/src/payload/index.ts b/packages/core-shared/src/payload/index.ts index c7c1937..c2190cd 100644 --- a/packages/core-shared/src/payload/index.ts +++ b/packages/core-shared/src/payload/index.ts @@ -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"; diff --git a/packages/core-shared/src/payload/payload-custom-ambient.d.ts b/packages/core-shared/src/payload/payload-custom-ambient.d.ts new file mode 100644 index 0000000..1279c63 --- /dev/null +++ b/packages/core-shared/src/payload/payload-custom-ambient.d.ts @@ -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). + // Augmenting it makes pii available on every field type. + interface FieldCustom { + pii?: FieldPii; + } + + // CollectionConfig.custom is typed as CollectionCustom (interface extending Record). + interface CollectionCustom { + retention?: CollectionRetention; + authPii?: Record; + } +} diff --git a/packages/core-shared/src/payload/pii-types.test.ts b/packages/core-shared/src/payload/pii-types.test.ts new file mode 100644 index 0000000..9b7a398 --- /dev/null +++ b/packages/core-shared/src/payload/pii-types.test.ts @@ -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(); + }); +}); diff --git a/packages/core-shared/src/payload/pii-types.ts b/packages/core-shared/src/payload/pii-types.ts new file mode 100644 index 0000000..eb9616e --- /dev/null +++ b/packages/core-shared/src/payload/pii-types.ts @@ -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); + +export type DataProcessingPurpose = + | "account-authentication" + | "transactional-notifications" + | "marketing-communications" + | "analytics-aggregation" + | "legal-compliance" + | "service-delivery" + | (string & Record); + +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 = { + 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, +}; diff --git a/packages/core-shared/src/payload/retention-types.test.ts b/packages/core-shared/src/payload/retention-types.test.ts new file mode 100644 index 0000000..27e5e7f --- /dev/null +++ b/packages/core-shared/src/payload/retention-types.test.ts @@ -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; + }); +}); diff --git a/packages/core-shared/src/payload/retention-types.ts b/packages/core-shared/src/payload/retention-types.ts new file mode 100644 index 0000000..9e8e95e --- /dev/null +++ b/packages/core-shared/src/payload/retention-types.ts @@ -0,0 +1,21 @@ +import type { RetentionAction } from "./pii-types"; + +export type PurgeSchedule = + | "daily" + | "weekly" + | "monthly" + | (string & Record); + +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" }; +}; diff --git a/packages/core-shared/vitest.config.ts b/packages/core-shared/vitest.config.ts index 2ee07c1..5f880f4 100644 --- a/packages/core-shared/vitest.config.ts +++ b/packages/core-shared/vitest.config.ts @@ -3,6 +3,24 @@ import { mergeConfig } from "vitest/config"; import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; 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: { alias: { "@": path.resolve(__dirname, "./src") }, },