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",
|
||||
"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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
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";
|
||||
|
||||
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") },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user