feat(scripts): add emit-data-map compliance script + tests
Adds scripts/compliance/emit-data-map.mjs which walks Payload collection configs (packages/*/integrations/cms/collections/*.ts), applies PAYLOAD_AUTH_PII_DEFAULTS + custom.authPii overrides, and emits a deterministic YAML PII inventory at compliance/data-map.yml. Supports --print (stdout) and --check (diff vs committed, exit 1 on mismatch) modes. Ships with 26 unit tests covering happy path, auth defaults, authPii overrides, --check match/mismatch, and empty collections. Wired as `compliance:data-map` root package script. Adds @typescript-eslint/parser to root devDependencies (already in workspace via core-eslint, now made explicit for scripts/ usage). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
35
compliance/data-map.yml
Normal file
35
compliance/data-map.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# compliance/data-map.yml — PII field inventory
|
||||||
|
# Generated by scripts/compliance/emit-data-map.mjs — do not edit manually.
|
||||||
|
# Run `pnpm compliance:data-map` to regenerate.
|
||||||
|
collections:
|
||||||
|
articles:
|
||||||
|
auth: false
|
||||||
|
piiFields: []
|
||||||
|
slug: articles
|
||||||
|
media:
|
||||||
|
auth: false
|
||||||
|
piiFields: []
|
||||||
|
slug: media
|
||||||
|
pages:
|
||||||
|
auth: false
|
||||||
|
piiFields: []
|
||||||
|
slug: pages
|
||||||
|
users:
|
||||||
|
auth: true
|
||||||
|
piiFields:
|
||||||
|
- category: identification-username
|
||||||
|
exportable: true
|
||||||
|
field: displayName
|
||||||
|
purpose:
|
||||||
|
- service-delivery
|
||||||
|
restrictable: true
|
||||||
|
source: field-tag
|
||||||
|
- category: contact-email
|
||||||
|
exportable: true
|
||||||
|
field: email
|
||||||
|
purpose:
|
||||||
|
- account-authentication
|
||||||
|
- transactional-notifications
|
||||||
|
restrictable: true
|
||||||
|
source: auth-default
|
||||||
|
slug: users
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-18T19:03:46.644Z",
|
"generatedAt": "2026-05-18T19:42:59.601Z",
|
||||||
"commit": "464df9a",
|
"commit": "3a73634",
|
||||||
"repo": {
|
"repo": {
|
||||||
"statements": 96.43,
|
"statements": 96.47,
|
||||||
"branches": 91.71,
|
"branches": 91.71,
|
||||||
"functions": 96.81,
|
"functions": 96.81,
|
||||||
"lines": 96.43,
|
"lines": 96.47,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 4205,
|
"lf": 4254,
|
||||||
"lh": 4055,
|
"lh": 4104,
|
||||||
"brf": 808,
|
"brf": 808,
|
||||||
"brh": 741,
|
"brh": 741,
|
||||||
"fnf": 251,
|
"fnf": 251,
|
||||||
@@ -31,13 +31,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@repo/blog": {
|
"@repo/blog": {
|
||||||
"statements": 96.36,
|
"statements": 96.35,
|
||||||
"branches": 88.65,
|
"branches": 88.65,
|
||||||
"functions": 100,
|
"functions": 100,
|
||||||
"lines": 96.36,
|
"lines": 96.35,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 741,
|
"lf": 739,
|
||||||
"lh": 714,
|
"lh": 712,
|
||||||
"brf": 141,
|
"brf": 141,
|
||||||
"brh": 125,
|
"brh": 125,
|
||||||
"fnf": 29,
|
"fnf": 29,
|
||||||
@@ -73,13 +73,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@repo/marketing-pages": {
|
"@repo/marketing-pages": {
|
||||||
"statements": 95.64,
|
"statements": 95.93,
|
||||||
"branches": 83.93,
|
"branches": 83.93,
|
||||||
"functions": 100,
|
"functions": 100,
|
||||||
"lines": 95.64,
|
"lines": 95.93,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 711,
|
"lf": 762,
|
||||||
"lh": 680,
|
"lh": 731,
|
||||||
"brf": 112,
|
"brf": 112,
|
||||||
"brh": 94,
|
"brh": 94,
|
||||||
"fnf": 32,
|
"fnf": 32,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"mutate": "node scripts/coverage/mutate.mjs",
|
"mutate": "node scripts/coverage/mutate.mjs",
|
||||||
"fallow": "fallow",
|
"fallow": "fallow",
|
||||||
"fallow:audit": "fallow audit --base main",
|
"fallow:audit": "fallow audit --base main",
|
||||||
|
"compliance:data-map": "node scripts/compliance/emit-data-map.mjs",
|
||||||
"work": "node scripts/work/cli.mjs",
|
"work": "node scripts/work/cli.mjs",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-hero/sandcastle": "*",
|
"@ai-hero/sandcastle": "*",
|
||||||
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
"zod": "^3.25.0",
|
"zod": "^3.25.0",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@stryker-mutator/core": "^8.7.0",
|
"@stryker-mutator/core": "^8.7.0",
|
||||||
|
|||||||
412
scripts/compliance/emit-data-map.mjs
Normal file
412
scripts/compliance/emit-data-map.mjs
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* emit-data-map.mjs — PII field inventory emitter.
|
||||||
|
*
|
||||||
|
* Walks packages/*\/src/integrations/cms/collections/*.ts, applies
|
||||||
|
* PAYLOAD_AUTH_PII_DEFAULTS + custom.authPii overrides, and emits a
|
||||||
|
* deterministic YAML PII inventory at compliance/data-map.yml.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/compliance/emit-data-map.mjs # write compliance/data-map.yml
|
||||||
|
* node scripts/compliance/emit-data-map.mjs --print # write to stdout
|
||||||
|
* node scripts/compliance/emit-data-map.mjs --check # diff vs committed file; exit 1 on mismatch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parse } from "@typescript-eslint/parser";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
export const OUTPUT_PATH = "compliance/data-map.yml";
|
||||||
|
|
||||||
|
// ---- Constants ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default PII classification for Payload auth fields.
|
||||||
|
* Null means the field is NOT PII and must be excluded from the data map.
|
||||||
|
* Mirrors packages/core-shared/src/payload/pii-types.ts PAYLOAD_AUTH_PII_DEFAULTS.
|
||||||
|
*/
|
||||||
|
export const PAYLOAD_AUTH_PII_DEFAULTS = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- AST parsing helpers ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively extract a plain JS value from an AST node.
|
||||||
|
* Returns undefined for unresolvable nodes (identifier references, etc.).
|
||||||
|
*/
|
||||||
|
function extractValue(node) {
|
||||||
|
if (!node) return null;
|
||||||
|
if (node.type === "Literal") return node.value;
|
||||||
|
if (node.type === "TSAsExpression") return extractValue(node.expression);
|
||||||
|
if (node.type === "Identifier") {
|
||||||
|
if (node.name === "true") return true;
|
||||||
|
if (node.name === "false") return false;
|
||||||
|
if (node.name === "null") return null;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (node.type === "ObjectExpression") {
|
||||||
|
const obj = {};
|
||||||
|
for (const prop of node.properties) {
|
||||||
|
if (prop.type !== "Property") continue;
|
||||||
|
const key =
|
||||||
|
prop.key.type === "Identifier"
|
||||||
|
? prop.key.name
|
||||||
|
: prop.key.type === "Literal"
|
||||||
|
? String(prop.key.value)
|
||||||
|
: null;
|
||||||
|
if (!key) continue;
|
||||||
|
const v = extractValue(prop.value);
|
||||||
|
if (v !== undefined) obj[key] = v;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (node.type === "ArrayExpression") {
|
||||||
|
return node.elements
|
||||||
|
.map((e) => (e ? extractValue(e) : null))
|
||||||
|
.filter((e) => e !== undefined);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Payload collection TypeScript file.
|
||||||
|
* Returns the first exported variable whose value is an object with a string
|
||||||
|
* `slug` field, or null on parse failure / missing file.
|
||||||
|
*/
|
||||||
|
export function parseCollectionFile(filePath) {
|
||||||
|
let src;
|
||||||
|
try {
|
||||||
|
src = fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let ast;
|
||||||
|
try {
|
||||||
|
ast = parse(src, {
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
loc: false,
|
||||||
|
range: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of ast.body) {
|
||||||
|
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
|
||||||
|
if (node.declaration.type !== "VariableDeclaration") continue;
|
||||||
|
for (const decl of node.declaration.declarations) {
|
||||||
|
if (!decl.init) continue;
|
||||||
|
const value = extractValue(decl.init);
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
typeof value.slug === "string"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Collection file discovery ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all Payload collection TypeScript files across packages.
|
||||||
|
*/
|
||||||
|
export function findCollectionFiles(repoRoot = REPO_ROOT) {
|
||||||
|
const packagesDir = path.join(repoRoot, "packages");
|
||||||
|
if (!fs.existsSync(packagesDir)) return [];
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
for (const pkg of fs.readdirSync(packagesDir).sort()) {
|
||||||
|
const collectionsDir = path.join(
|
||||||
|
packagesDir,
|
||||||
|
pkg,
|
||||||
|
"src",
|
||||||
|
"integrations",
|
||||||
|
"cms",
|
||||||
|
"collections",
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(collectionsDir)) continue;
|
||||||
|
for (const file of fs.readdirSync(collectionsDir).sort()) {
|
||||||
|
if (file.endsWith(".ts") && !file.endsWith(".test.ts")) {
|
||||||
|
files.push(path.join(collectionsDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Data map builder ----
|
||||||
|
|
||||||
|
/** Build a single PII entry. */
|
||||||
|
function makePiiEntry(fieldName, pii, source) {
|
||||||
|
return {
|
||||||
|
field: fieldName,
|
||||||
|
source,
|
||||||
|
category: pii.category,
|
||||||
|
purpose: Array.isArray(pii.purpose) ? [...pii.purpose] : [],
|
||||||
|
exportable: pii.exportable ?? false,
|
||||||
|
restrictable: pii.restrictable ?? false,
|
||||||
|
...(pii.retention ? { retention: { ...pii.retention } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect field-level PII entries from a collection's fields array. */
|
||||||
|
function collectFieldPiiEntries(fields) {
|
||||||
|
const entries = [];
|
||||||
|
if (!Array.isArray(fields)) return entries;
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!field || typeof field.name !== "string") continue;
|
||||||
|
const pii = field.custom && field.custom.pii;
|
||||||
|
if (!pii || typeof pii !== "object") continue;
|
||||||
|
entries.push(makePiiEntry(field.name, pii, "field-tag"));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect auth PII entries by merging defaults with per-collection overrides. */
|
||||||
|
function collectAuthPiiEntries(authPiiDefaults, authPiiOverrides) {
|
||||||
|
const merged = { ...authPiiDefaults, ...authPiiOverrides };
|
||||||
|
const entries = [];
|
||||||
|
for (const [fieldName, pii] of Object.entries(merged)) {
|
||||||
|
if (pii === null) continue;
|
||||||
|
const source =
|
||||||
|
fieldName in authPiiOverrides ? "auth-override" : "auth-default";
|
||||||
|
entries.push(makePiiEntry(fieldName, pii, source));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk parsed collection configs and build the PII data map.
|
||||||
|
* Applies authPiiDefaults (PAYLOAD_AUTH_PII_DEFAULTS) and custom.authPii
|
||||||
|
* overrides for auth-enabled collections.
|
||||||
|
*
|
||||||
|
* Returns: Record<slug, { auth, piiFields, slug }>
|
||||||
|
*/
|
||||||
|
export function buildDataMap(
|
||||||
|
rawCollections,
|
||||||
|
authPiiDefaults = PAYLOAD_AUTH_PII_DEFAULTS,
|
||||||
|
) {
|
||||||
|
const map = {};
|
||||||
|
for (const coll of rawCollections) {
|
||||||
|
if (!coll || typeof coll.slug !== "string") continue;
|
||||||
|
const isAuth = coll.auth === true;
|
||||||
|
|
||||||
|
const piiFields = [
|
||||||
|
...collectFieldPiiEntries(coll.fields),
|
||||||
|
...(isAuth
|
||||||
|
? collectAuthPiiEntries(
|
||||||
|
authPiiDefaults,
|
||||||
|
(coll.custom && coll.custom.authPii) || {},
|
||||||
|
)
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
piiFields.sort((a, b) => a.field.localeCompare(b.field));
|
||||||
|
|
||||||
|
map[coll.slug] = { auth: isAuth, piiFields, slug: coll.slug };
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- YAML serialization ----
|
||||||
|
|
||||||
|
const YAML_HEADER = [
|
||||||
|
"# compliance/data-map.yml — PII field inventory",
|
||||||
|
"# Generated by scripts/compliance/emit-data-map.mjs — do not edit manually.",
|
||||||
|
"# Run `pnpm compliance:data-map` to regenerate.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
/** Quote a YAML string scalar only when necessary. */
|
||||||
|
function yamlStr(s) {
|
||||||
|
if (typeof s !== "string") return String(s);
|
||||||
|
if (
|
||||||
|
s === "" ||
|
||||||
|
["true", "false", "null", "yes", "no", "on", "off"].includes(s) ||
|
||||||
|
/[[{},:#&*!|>'"%@`\]]/u.test(s) ||
|
||||||
|
/^\s|\s$/.test(s)
|
||||||
|
) {
|
||||||
|
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPiiField(f) {
|
||||||
|
let out = "";
|
||||||
|
out += ` - category: ${yamlStr(f.category)}\n`;
|
||||||
|
out += ` exportable: ${f.exportable}\n`;
|
||||||
|
out += ` field: ${yamlStr(f.field)}\n`;
|
||||||
|
|
||||||
|
if (!f.purpose || f.purpose.length === 0) {
|
||||||
|
out += ` purpose: []\n`;
|
||||||
|
} else {
|
||||||
|
out += ` purpose:\n`;
|
||||||
|
for (const p of f.purpose) {
|
||||||
|
out += ` - ${yamlStr(p)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out += ` restrictable: ${f.restrictable}\n`;
|
||||||
|
|
||||||
|
if (f.retention) {
|
||||||
|
out += ` retention:\n`;
|
||||||
|
out += ` action: ${yamlStr(f.retention.action)}\n`;
|
||||||
|
out += ` duration: ${yamlStr(f.retention.duration)}\n`;
|
||||||
|
out += ` trigger: ${yamlStr(f.retention.trigger)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += ` source: ${yamlStr(f.source)}\n`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the data map as deterministic YAML.
|
||||||
|
* Collections sorted by slug; piiFields sorted by field name (done in buildDataMap).
|
||||||
|
*/
|
||||||
|
export function renderDataMapYaml(dataMap) {
|
||||||
|
let yaml = YAML_HEADER + "\n";
|
||||||
|
yaml += "collections:\n";
|
||||||
|
|
||||||
|
for (const slug of Object.keys(dataMap).sort()) {
|
||||||
|
const coll = dataMap[slug];
|
||||||
|
yaml += ` ${yamlStr(slug)}:\n`;
|
||||||
|
yaml += ` auth: ${coll.auth}\n`;
|
||||||
|
|
||||||
|
if (!coll.piiFields || coll.piiFields.length === 0) {
|
||||||
|
yaml += ` piiFields: []\n`;
|
||||||
|
} else {
|
||||||
|
yaml += ` piiFields:\n`;
|
||||||
|
for (const f of coll.piiFields) {
|
||||||
|
yaml += renderPiiField(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yaml += ` slug: ${yamlStr(slug)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return yaml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Diff helper for --check mode ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a readable line-diff between expected (committed) and actual (generated).
|
||||||
|
* Returns null if equal.
|
||||||
|
*/
|
||||||
|
export function unifiedDiff(expected, actual, filename) {
|
||||||
|
const expLines = expected.split("\n");
|
||||||
|
const actLines = actual.split("\n");
|
||||||
|
const maxLen = Math.max(expLines.length, actLines.length);
|
||||||
|
|
||||||
|
const hunks = [];
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
const expLine = expLines[i] ?? "";
|
||||||
|
const actLine = actLines[i] ?? "";
|
||||||
|
if (expLine !== actLine) {
|
||||||
|
hunks.push(`Line ${i + 1}:`);
|
||||||
|
hunks.push(` - ${expLine}`);
|
||||||
|
hunks.push(` + ${actLine}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hunks.length === 0) return null;
|
||||||
|
return (
|
||||||
|
`--- ${filename} (committed)\n` +
|
||||||
|
`+++ ${filename} (generated)\n` +
|
||||||
|
hunks.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CLI ----
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = { mode: "write" }; // 'write' | 'print' | 'check'
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
if (argv[i] === "--print") out.mode = "print";
|
||||||
|
else if (argv[i] === "--check") out.mode = "check";
|
||||||
|
else if (argv[i] === "--help" || argv[i] === "-h") {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
"Usage: node scripts/compliance/emit-data-map.mjs [--print | --check]",
|
||||||
|
" (default): write compliance/data-map.yml",
|
||||||
|
" --print: write YAML to stdout",
|
||||||
|
" --check: diff vs committed file; exit 1 on mismatch",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
|
||||||
|
const files = findCollectionFiles(repoRoot);
|
||||||
|
const rawCollections = files
|
||||||
|
.map((f) => parseCollectionFile(f))
|
||||||
|
.filter(Boolean);
|
||||||
|
const dataMap = buildDataMap(rawCollections);
|
||||||
|
const yaml = renderDataMapYaml(dataMap);
|
||||||
|
|
||||||
|
const outPath = path.resolve(repoRoot, OUTPUT_PATH);
|
||||||
|
|
||||||
|
if (args.mode === "print") {
|
||||||
|
process.stdout.write(yaml);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.mode === "check") {
|
||||||
|
if (!fs.existsSync(outPath)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[compliance:data-map] --check: no committed file at ${OUTPUT_PATH}\n` +
|
||||||
|
`Run \`pnpm compliance:data-map\` to generate it first.\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const committed = fs.readFileSync(outPath, "utf8");
|
||||||
|
const diff = unifiedDiff(committed, yaml, OUTPUT_PATH);
|
||||||
|
if (diff === null) {
|
||||||
|
console.log(`✓ compliance:data-map — ${OUTPUT_PATH} is up to date`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.stderr.write(
|
||||||
|
`✗ compliance:data-map — ${OUTPUT_PATH} is out of date:\n${diff}\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: write to file
|
||||||
|
const dir = path.dirname(outPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(outPath, yaml, "utf8");
|
||||||
|
console.log(`✓ compliance:data-map — wrote ${OUTPUT_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
354
scripts/compliance/emit-data-map.test.mjs
Normal file
354
scripts/compliance/emit-data-map.test.mjs
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { test, describe } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
findCollectionFiles,
|
||||||
|
parseCollectionFile,
|
||||||
|
buildDataMap,
|
||||||
|
renderDataMapYaml,
|
||||||
|
unifiedDiff,
|
||||||
|
} from "./emit-data-map.mjs";
|
||||||
|
|
||||||
|
// ---- Fixtures ----
|
||||||
|
|
||||||
|
const USERS_TS = `
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
export const users: CollectionConfig = {
|
||||||
|
slug: "users",
|
||||||
|
auth: true,
|
||||||
|
custom: {
|
||||||
|
retention: { purgeSchedule: "daily", postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" } },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "displayName",
|
||||||
|
type: "text",
|
||||||
|
custom: {
|
||||||
|
pii: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "role", type: "select" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ARTICLES_TS = `
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
export const articles: CollectionConfig = {
|
||||||
|
slug: "articles",
|
||||||
|
custom: {
|
||||||
|
retention: { purgeSchedule: "monthly", postDeletion: { duration: "P90D", trigger: "after-deletion", action: "hard-delete" } },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: "title", type: "text" },
|
||||||
|
{ name: "content", type: "richText" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AUTH_PII_OVERRIDE_TS = `
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
export const members: CollectionConfig = {
|
||||||
|
slug: "members",
|
||||||
|
auth: true,
|
||||||
|
custom: {
|
||||||
|
retention: { purgeSchedule: "daily" },
|
||||||
|
authPii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication", "marketing-communications"],
|
||||||
|
exportable: false,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
password: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---- Test helpers ----
|
||||||
|
|
||||||
|
function makeRepo(collections) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-"));
|
||||||
|
for (const [pkgSlug, src] of Object.entries(collections)) {
|
||||||
|
const dir = path.join(
|
||||||
|
root,
|
||||||
|
"packages",
|
||||||
|
pkgSlug,
|
||||||
|
"src",
|
||||||
|
"integrations",
|
||||||
|
"cms",
|
||||||
|
"collections",
|
||||||
|
);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, `${pkgSlug}.ts`), src, "utf8");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMapFromFixtures(fixtures) {
|
||||||
|
const root = makeRepo(fixtures);
|
||||||
|
const files = findCollectionFiles(root);
|
||||||
|
const raw = files.map((f) => parseCollectionFile(f)).filter(Boolean);
|
||||||
|
return buildDataMap(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tests ----
|
||||||
|
|
||||||
|
describe("findCollectionFiles", () => {
|
||||||
|
test("returns all .ts files in packages/*/integrations/cms/collections/", () => {
|
||||||
|
const root = makeRepo({ blog: ARTICLES_TS, auth: USERS_TS });
|
||||||
|
const files = findCollectionFiles(root);
|
||||||
|
assert.equal(files.length, 2);
|
||||||
|
assert.ok(files.every((f) => f.endsWith(".ts")));
|
||||||
|
assert.ok(files.some((f) => f.includes("auth")));
|
||||||
|
assert.ok(files.some((f) => f.includes("blog")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array when packages/ does not exist", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-empty-"));
|
||||||
|
assert.deepEqual(findCollectionFiles(root), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes .test.ts files", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-test-"));
|
||||||
|
const dir = path.join(
|
||||||
|
root,
|
||||||
|
"packages",
|
||||||
|
"blog",
|
||||||
|
"src",
|
||||||
|
"integrations",
|
||||||
|
"cms",
|
||||||
|
"collections",
|
||||||
|
);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, "articles.ts"), ARTICLES_TS, "utf8");
|
||||||
|
fs.writeFileSync(path.join(dir, "articles.test.ts"), "// test", "utf8");
|
||||||
|
const files = findCollectionFiles(root);
|
||||||
|
assert.equal(files.length, 1);
|
||||||
|
assert.ok(!files[0].endsWith(".test.ts"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseCollectionFile", () => {
|
||||||
|
test("extracts slug, auth, fields, and custom from a collection file", () => {
|
||||||
|
const root = makeRepo({ auth: USERS_TS });
|
||||||
|
const [file] = findCollectionFiles(root);
|
||||||
|
const coll = parseCollectionFile(file);
|
||||||
|
assert.equal(coll.slug, "users");
|
||||||
|
assert.equal(coll.auth, true);
|
||||||
|
assert.ok(Array.isArray(coll.fields));
|
||||||
|
assert.equal(coll.fields.length, 2);
|
||||||
|
assert.equal(coll.fields[0].name, "displayName");
|
||||||
|
assert.equal(coll.fields[0].custom.pii.category, "identification-username");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for an unreadable path", () => {
|
||||||
|
assert.equal(parseCollectionFile("/nonexistent/path.ts"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts a collection without auth or PII", () => {
|
||||||
|
const root = makeRepo({ blog: ARTICLES_TS });
|
||||||
|
const [file] = findCollectionFiles(root);
|
||||||
|
const coll = parseCollectionFile(file);
|
||||||
|
assert.equal(coll.slug, "articles");
|
||||||
|
assert.equal(coll.auth, undefined);
|
||||||
|
assert.equal(coll.fields.length, 2);
|
||||||
|
assert.ok(!coll.fields[0].custom);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDataMap — empty collections", () => {
|
||||||
|
test("returns empty map for no collections", () => {
|
||||||
|
assert.deepEqual(buildDataMap([]), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes collection with no PII fields as piiFields: []", () => {
|
||||||
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
||||||
|
assert.ok("articles" in map);
|
||||||
|
assert.deepEqual(map.articles.piiFields, []);
|
||||||
|
assert.equal(map.articles.auth, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDataMap — auth defaults applied", () => {
|
||||||
|
test("applies PAYLOAD_AUTH_PII_DEFAULTS for auth-enabled collections", () => {
|
||||||
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
||||||
|
const emailField = map.users.piiFields.find((f) => f.field === "email");
|
||||||
|
assert.ok(emailField, "email field should be present from auth defaults");
|
||||||
|
assert.equal(emailField.category, "contact-email");
|
||||||
|
assert.equal(emailField.source, "auth-default");
|
||||||
|
assert.deepEqual(emailField.purpose, [
|
||||||
|
"account-authentication",
|
||||||
|
"transactional-notifications",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes null-valued auth defaults (e.g. password, salt)", () => {
|
||||||
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
||||||
|
const nullFields = ["password", "salt", "hash", "resetPasswordToken"];
|
||||||
|
for (const name of nullFields) {
|
||||||
|
const found = map.users.piiFields.find((f) => f.field === name);
|
||||||
|
assert.equal(
|
||||||
|
found,
|
||||||
|
undefined,
|
||||||
|
`${name} should be excluded (null default)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does NOT apply auth defaults for non-auth collections", () => {
|
||||||
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
||||||
|
assert.deepEqual(map.articles.piiFields, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes field-level PII tags alongside auth defaults", () => {
|
||||||
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
||||||
|
const displayName = map.users.piiFields.find(
|
||||||
|
(f) => f.field === "displayName",
|
||||||
|
);
|
||||||
|
const email = map.users.piiFields.find((f) => f.field === "email");
|
||||||
|
assert.ok(displayName, "displayName should come from field-tag");
|
||||||
|
assert.equal(displayName.source, "field-tag");
|
||||||
|
assert.ok(email, "email should come from auth-default");
|
||||||
|
assert.equal(email.source, "auth-default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("piiFields are sorted by field name", () => {
|
||||||
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
||||||
|
const names = map.users.piiFields.map((f) => f.field);
|
||||||
|
assert.deepEqual(names, [...names].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDataMap — authPii override applied", () => {
|
||||||
|
test("custom.authPii overrides PAYLOAD_AUTH_PII_DEFAULTS", () => {
|
||||||
|
const map = buildMapFromFixtures({ members: AUTH_PII_OVERRIDE_TS });
|
||||||
|
const emailField = map.members.piiFields.find((f) => f.field === "email");
|
||||||
|
assert.ok(emailField, "email should be present");
|
||||||
|
assert.equal(emailField.source, "auth-override");
|
||||||
|
assert.equal(emailField.exportable, false);
|
||||||
|
assert.ok(
|
||||||
|
emailField.purpose.includes("marketing-communications"),
|
||||||
|
"overridden purpose should be used",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authPii null overrides exclude the field", () => {
|
||||||
|
const map = buildMapFromFixtures({ members: AUTH_PII_OVERRIDE_TS });
|
||||||
|
const pwField = map.members.piiFields.find((f) => f.field === "password");
|
||||||
|
assert.equal(
|
||||||
|
pwField,
|
||||||
|
undefined,
|
||||||
|
"password null override should be excluded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderDataMapYaml", () => {
|
||||||
|
test("renders collections in alphabetical order", () => {
|
||||||
|
const map = {
|
||||||
|
users: { auth: true, piiFields: [], slug: "users" },
|
||||||
|
articles: { auth: false, piiFields: [], slug: "articles" },
|
||||||
|
};
|
||||||
|
const yaml = renderDataMapYaml(map);
|
||||||
|
const articlesIdx = yaml.indexOf(" articles:");
|
||||||
|
const usersIdx = yaml.indexOf(" users:");
|
||||||
|
assert.ok(articlesIdx < usersIdx, "articles should come before users");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders piiFields: [] for empty fields", () => {
|
||||||
|
const map = { articles: { auth: false, piiFields: [], slug: "articles" } };
|
||||||
|
const yaml = renderDataMapYaml(map);
|
||||||
|
assert.ok(yaml.includes("piiFields: []"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders PII field with all required keys", () => {
|
||||||
|
const map = {
|
||||||
|
users: {
|
||||||
|
auth: true,
|
||||||
|
slug: "users",
|
||||||
|
piiFields: [
|
||||||
|
{
|
||||||
|
field: "email",
|
||||||
|
source: "auth-default",
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const yaml = renderDataMapYaml(map);
|
||||||
|
assert.ok(yaml.includes("field: email"));
|
||||||
|
assert.ok(yaml.includes("category: contact-email"));
|
||||||
|
assert.ok(yaml.includes("source: auth-default"));
|
||||||
|
assert.ok(yaml.includes("exportable: true"));
|
||||||
|
assert.ok(yaml.includes("restrictable: true"));
|
||||||
|
assert.ok(yaml.includes("- account-authentication"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rendered YAML includes header comment", () => {
|
||||||
|
const yaml = renderDataMapYaml({});
|
||||||
|
assert.ok(yaml.startsWith("# compliance/data-map.yml"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("output is deterministic across multiple calls", () => {
|
||||||
|
const map = buildMapFromFixtures({ auth: USERS_TS, blog: ARTICLES_TS });
|
||||||
|
assert.equal(renderDataMapYaml(map), renderDataMapYaml(map));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unifiedDiff", () => {
|
||||||
|
test("returns null when strings are equal", () => {
|
||||||
|
assert.equal(unifiedDiff("a\nb\n", "a\nb\n", "file.yml"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a readable diff when strings differ", () => {
|
||||||
|
const diff = unifiedDiff("line1\nold\n", "line1\nnew\n", "file.yml");
|
||||||
|
assert.ok(diff !== null);
|
||||||
|
assert.ok(diff.includes("--- file.yml"));
|
||||||
|
assert.ok(diff.includes("+++ file.yml"));
|
||||||
|
assert.ok(diff.includes("- old"));
|
||||||
|
assert.ok(diff.includes("+ new"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diff includes line numbers", () => {
|
||||||
|
const diff = unifiedDiff("a\n", "b\n", "x.yml");
|
||||||
|
assert.ok(diff.includes("Line 1:"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles different lengths (extra lines)", () => {
|
||||||
|
const diff = unifiedDiff("a\nb\n", "a\nb\nc\n", "x.yml");
|
||||||
|
assert.ok(diff !== null);
|
||||||
|
assert.ok(diff.includes("+ c"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("--check mode (integration)", () => {
|
||||||
|
test("passes when committed file matches generated output", () => {
|
||||||
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
||||||
|
const yaml = renderDataMapYaml(map);
|
||||||
|
const diff = unifiedDiff(yaml, yaml, "compliance/data-map.yml");
|
||||||
|
assert.equal(diff, null, "no diff expected when file matches");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when committed file does not match generated output", () => {
|
||||||
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
||||||
|
const yaml = renderDataMapYaml(map);
|
||||||
|
const stale = "# stale content\ncollections: {}\n";
|
||||||
|
const diff = unifiedDiff(stale, yaml, "compliance/data-map.yml");
|
||||||
|
assert.ok(diff !== null, "diff expected when file is stale");
|
||||||
|
assert.ok(diff.includes("- # stale content"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user