feat(core-shared): add subject-linkage types and extend PII defaults

Introduces SubjectLinkKind, SubjectLink, and CollectionSubject types to
packages/core-shared/src/payload/subject-linkage-types.ts, establishes the
ambient CollectionCustom.subject declaration (parallel to custom.pii / custom.retention
from Epic A), and extends PAYLOAD_AUTH_PII_DEFAULTS with processingRestrictedAt and
consentState as DSR-managed excluded fields. Applies the first canonical usage of
custom.subject = { kind: "self", field: "id" } on the auth users collection.
This commit is contained in:
2026-05-19 10:11:09 +00:00
parent 5abf7fe623
commit f8bb2f4094
8 changed files with 125 additions and 24 deletions

View File

@@ -1,18 +1,18 @@
{ {
"generatedAt": "2026-05-18T20:21:48.917Z", "generatedAt": "2026-05-19T10:10:24.367Z",
"commit": "188625b", "commit": "5abf7fe",
"repo": { "repo": {
"statements": 96.47, "statements": 96.48,
"branches": 91.71, "branches": 91.72,
"functions": 96.81, "functions": 96.83,
"lines": 96.47, "lines": 96.48,
"counts": { "counts": {
"lf": 4254, "lf": 4264,
"lh": 4104, "lh": 4114,
"brf": 808, "brf": 809,
"brh": 741, "brh": 742,
"fnf": 251, "fnf": 252,
"fnh": 243 "fnh": 244
} }
}, },
"byPackage": { "byPackage": {
@@ -59,17 +59,17 @@
} }
}, },
"@repo/core-shared": { "@repo/core-shared": {
"statements": 97.98, "statements": 98,
"branches": 95.79, "branches": 95.81,
"functions": 91.92, "functions": 92,
"lines": 97.98, "lines": 98,
"counts": { "counts": {
"lf": 1040, "lf": 1050,
"lh": 1019, "lh": 1029,
"brf": 309, "brf": 310,
"brh": 296, "brh": 297,
"fnf": 99, "fnf": 100,
"fnh": 91 "fnh": 92
} }
}, },
"@repo/marketing-pages": { "@repo/marketing-pages": {

View File

@@ -15,6 +15,7 @@ export const users: CollectionConfig = {
action: "hard-delete", action: "hard-delete",
}, },
}, },
subject: { kind: "self", field: "id" },
}, },
fields: [ fields: [
{ {

View File

@@ -14,6 +14,11 @@ export type {
} from "./pii-types"; } from "./pii-types";
export { PAYLOAD_AUTH_PII_DEFAULTS } from "./pii-types"; export { PAYLOAD_AUTH_PII_DEFAULTS } from "./pii-types";
export type { PurgeSchedule, CollectionRetention } from "./retention-types"; export type { PurgeSchedule, CollectionRetention } from "./retention-types";
export type {
SubjectLinkKind,
SubjectLink,
CollectionSubject,
} from "./subject-linkage-types";
export { export {
parseDurationMs, parseDurationMs,
scheduleDelayMs, scheduleDelayMs,

View File

@@ -1,5 +1,6 @@
import type { FieldPii } from "./pii-types"; import type { FieldPii } from "./pii-types";
import type { CollectionRetention } from "./retention-types"; import type { CollectionRetention } from "./retention-types";
import type { CollectionSubject } from "./subject-linkage-types";
declare module "payload" { declare module "payload" {
// FieldBase.custom is typed as FieldCustom (interface extending Record<string, any>). // FieldBase.custom is typed as FieldCustom (interface extending Record<string, any>).
@@ -12,5 +13,6 @@ declare module "payload" {
interface CollectionCustom { interface CollectionCustom {
retention?: CollectionRetention; retention?: CollectionRetention;
authPii?: Record<string, FieldPii | null>; authPii?: Record<string, FieldPii | null>;
subject?: CollectionSubject | CollectionSubject[];
} }
} }

View File

@@ -13,6 +13,8 @@ const CREDENTIAL_FIELDS = [
"apiKeyIndex", "apiKeyIndex",
] as const; ] as const;
const DSR_MANAGED_FIELDS = ["processingRestrictedAt", "consentState"] as const;
describe("FieldPii type safety", () => { describe("FieldPii type safety", () => {
it("rejects FieldPii missing required fields at compile time", () => { it("rejects FieldPii missing required fields at compile time", () => {
// @ts-expect-error — 'purpose', 'exportable', 'restrictable' are required // @ts-expect-error — 'purpose', 'exportable', 'restrictable' are required
@@ -88,8 +90,14 @@ describe("PAYLOAD_AUTH_PII_DEFAULTS", () => {
expect(emailPii?.restrictable).toBe(true); expect(emailPii?.restrictable).toBe(true);
}); });
it("has exactly 10 keys: email plus 9 credential fields", () => { it("has exactly 12 keys: email, 9 credential fields, and 2 DSR-managed fields", () => {
expect(Object.keys(PAYLOAD_AUTH_PII_DEFAULTS)).toHaveLength(10); expect(Object.keys(PAYLOAD_AUTH_PII_DEFAULTS)).toHaveLength(12);
});
it("sets DSR-managed fields to null", () => {
for (const field of DSR_MANAGED_FIELDS) {
expect(PAYLOAD_AUTH_PII_DEFAULTS[field]).toBeNull();
}
}); });
it("email has no retention override (falls back to collection-level)", () => { it("email has no retention override (falls back to collection-level)", () => {

View File

@@ -61,4 +61,6 @@ export const PAYLOAD_AUTH_PII_DEFAULTS: Record<string, FieldPii | null> = {
lockUntil: null, lockUntil: null,
apiKey: null, apiKey: null,
apiKeyIndex: null, apiKeyIndex: null,
processingRestrictedAt: null,
consentState: null,
}; };

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type {
CollectionSubject,
SubjectLink,
SubjectLinkKind,
} from "./subject-linkage-types";
describe("SubjectLinkKind", () => {
it("accepts all valid kinds", () => {
const kinds: SubjectLinkKind[] = ["self", "owner", "reference"];
expect(kinds).toHaveLength(3);
});
});
describe("SubjectLink type safety", () => {
it("accepts a minimal self-link", () => {
const link: SubjectLink = { field: "id", kind: "self" };
expect(link.field).toBe("id");
expect(link.kind).toBe("self");
expect(link.target).toBeUndefined();
expect(link.role).toBeUndefined();
});
it("accepts a reference link with target and role", () => {
const link: SubjectLink = {
field: "createdBy",
kind: "reference",
target: "users",
role: "author",
};
expect(link.target).toBe("users");
expect(link.role).toBe("author");
});
it("accepts an owner link with target", () => {
const link: SubjectLink = {
field: "userId",
kind: "owner",
target: "users",
};
expect(link.kind).toBe("owner");
expect(link.target).toBe("users");
});
it("rejects a SubjectLink missing required field at compile time", () => {
// @ts-expect-error — 'field' is required
const _missing: SubjectLink = { kind: "self" };
void _missing;
});
it("rejects a SubjectLink missing required kind at compile time", () => {
// @ts-expect-error — 'kind' is required
const _missing: SubjectLink = { field: "id" };
void _missing;
});
});
describe("CollectionSubject", () => {
it("is assignable from a SubjectLink", () => {
const subject: CollectionSubject = { field: "id", kind: "self" };
expect(subject.kind).toBe("self");
});
it("accepts an array of CollectionSubject entries for multi-linkage", () => {
const subjects: CollectionSubject[] = [
{ field: "id", kind: "self" },
{ field: "authorId", kind: "owner", target: "users" },
];
expect(subjects).toHaveLength(2);
expect(subjects[0]?.kind).toBe("self");
expect(subjects[1]?.kind).toBe("owner");
});
});

View File

@@ -0,0 +1,10 @@
export type SubjectLinkKind = "self" | "owner" | "reference";
export type SubjectLink = {
field: string;
kind: SubjectLinkKind;
target?: string;
role?: string;
};
export type CollectionSubject = SubjectLink;