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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ const CREDENTIAL_FIELDS = [
"apiKeyIndex",
] as const;
const DSR_MANAGED_FIELDS = ["processingRestrictedAt", "consentState"] as const;
describe("FieldPii type safety", () => {
it("rejects FieldPii missing required fields at compile time", () => {
// @ts-expect-error — 'purpose', 'exportable', 'restrictable' are required
@@ -88,8 +90,14 @@ describe("PAYLOAD_AUTH_PII_DEFAULTS", () => {
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("has exactly 12 keys: email, 9 credential fields, and 2 DSR-managed fields", () => {
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)", () => {

View File

@@ -61,4 +61,6 @@ export const PAYLOAD_AUTH_PII_DEFAULTS: Record<string, FieldPii | null> = {
lockUntil: null,
apiKey: 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;