@rehearsal-db/core 0.1.0-beta.15 → 0.1.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/dist/package.json +10 -9
  3. package/dist/src/baseline/privacy_contract.d.mts +147 -0
  4. package/dist/src/baseline/privacy_contract.mjs +2 -0
  5. package/dist/src/baseline/privacy_dependency_graph.d.mts +7 -0
  6. package/dist/src/baseline/privacy_dependency_graph.mjs +110 -0
  7. package/dist/src/baseline/privacy_engine.d.mts +5 -162
  8. package/dist/src/baseline/privacy_engine.mjs +4 -1162
  9. package/dist/src/baseline/privacy_key.d.mts +7 -0
  10. package/dist/src/baseline/privacy_key.mjs +42 -0
  11. package/dist/src/baseline/privacy_pipeline.d.mts +7 -0
  12. package/dist/src/baseline/privacy_pipeline.mjs +127 -0
  13. package/dist/src/baseline/privacy_policy.d.mts +6 -0
  14. package/dist/src/baseline/privacy_policy.mjs +237 -0
  15. package/dist/src/baseline/privacy_recipe_validation.d.mts +5 -0
  16. package/dist/src/baseline/privacy_recipe_validation.mjs +289 -0
  17. package/dist/src/baseline/privacy_rule_registry.d.mts +18 -0
  18. package/dist/src/baseline/privacy_rule_registry.mjs +143 -0
  19. package/dist/src/baseline/privacy_transforms.d.mts +18 -0
  20. package/dist/src/baseline/privacy_transforms.mjs +142 -0
  21. package/dist/src/baseline/privacy_validation_shared.d.mts +31 -0
  22. package/dist/src/baseline/privacy_validation_shared.mjs +127 -0
  23. package/dist/src/cli/application_command_handlers.d.mts +8 -0
  24. package/dist/src/cli/application_command_handlers.mjs +52 -0
  25. package/dist/src/cli/arguments.d.mts +0 -5
  26. package/dist/src/cli/arguments.mjs +0 -7
  27. package/dist/src/cli/basic_command_handlers.d.mts +4 -0
  28. package/dist/src/cli/basic_command_handlers.mjs +56 -0
  29. package/dist/src/cli/cli_commands.d.mts +14 -0
  30. package/dist/src/cli/cli_commands.mjs +32 -0
  31. package/dist/src/cli/command_contract.d.mts +57 -0
  32. package/dist/src/cli/command_contract.mjs +39 -0
  33. package/dist/src/cli/command_output.d.mts +12 -0
  34. package/dist/src/cli/command_output.mjs +20 -0
  35. package/dist/src/cli/command_registry.d.mts +7 -0
  36. package/dist/src/cli/command_registry.mjs +14 -0
  37. package/dist/src/cli/guided.d.mts +3 -1
  38. package/dist/src/cli/guided.mjs +13 -7
  39. package/dist/src/cli/identity_command_handlers.d.mts +8 -0
  40. package/dist/src/cli/identity_command_handlers.mjs +43 -0
  41. package/dist/src/cli/init_command.d.mts +1 -1
  42. package/dist/src/cli/inspection_command_handlers.d.mts +3 -0
  43. package/dist/src/cli/inspection_command_handlers.mjs +79 -0
  44. package/dist/src/cli/project_command_handlers.d.mts +11 -0
  45. package/dist/src/cli/project_command_handlers.mjs +92 -0
  46. package/dist/src/cli/project_commands.d.mts +6 -4
  47. package/dist/src/cli/project_commands.mjs +6 -6
  48. package/dist/src/cli/rehearsal.d.mts +2 -2
  49. package/dist/src/cli/rehearsal.mjs +51 -567
  50. package/dist/src/cli/runtime_action_handlers.d.mts +12 -0
  51. package/dist/src/cli/runtime_action_handlers.mjs +201 -0
  52. package/dist/src/cli/runtime_commands.d.mts +7 -3
  53. package/dist/src/cli/runtime_commands.mjs +10 -8
  54. package/dist/src/cli/source_command_handlers.d.mts +8 -0
  55. package/dist/src/cli/source_command_handlers.mjs +62 -0
  56. package/dist/src/cli/source_commands.d.mts +5 -1
  57. package/dist/src/cli/source_commands.mjs +3 -1
  58. package/dist/src/identity/claim.d.mts +5 -144
  59. package/dist/src/identity/claim.mjs +4 -947
  60. package/dist/src/identity/claim_contract.d.mts +135 -0
  61. package/dist/src/identity/claim_contract.mjs +6 -0
  62. package/dist/src/identity/claim_executor.d.mts +10 -0
  63. package/dist/src/identity/claim_executor.mjs +358 -0
  64. package/dist/src/identity/claim_identity_lookup.d.mts +17 -0
  65. package/dist/src/identity/claim_identity_lookup.mjs +125 -0
  66. package/dist/src/identity/claim_plan.d.mts +8 -0
  67. package/dist/src/identity/claim_plan.mjs +61 -0
  68. package/dist/src/identity/claim_policy.d.mts +3 -0
  69. package/dist/src/identity/claim_policy.mjs +377 -0
  70. package/dist/src/identity/claim_sql.d.mts +10 -0
  71. package/dist/src/identity/claim_sql.mjs +46 -0
  72. package/dist/src/identity/claim_verification.d.mts +13 -0
  73. package/dist/src/identity/claim_verification.mjs +35 -0
  74. package/dist/src/index.d.mts +1 -1
  75. package/dist/src/project/configuration.d.mts +11 -179
  76. package/dist/src/project/configuration.mjs +7 -956
  77. package/dist/src/project/configuration_contract.d.mts +125 -0
  78. package/dist/src/project/configuration_contract.mjs +27 -0
  79. package/dist/src/project/configuration_discovery.d.mts +7 -0
  80. package/dist/src/project/configuration_discovery.mjs +37 -0
  81. package/dist/src/project/configuration_normalization.d.mts +107 -0
  82. package/dist/src/project/configuration_normalization.mjs +302 -0
  83. package/dist/src/project/configuration_rendering.d.mts +14 -0
  84. package/dist/src/project/configuration_rendering.mjs +224 -0
  85. package/dist/src/project/configuration_resolution.d.mts +125 -0
  86. package/dist/src/project/configuration_resolution.mjs +81 -0
  87. package/dist/src/project/configuration_schema.d.mts +22 -0
  88. package/dist/src/project/configuration_schema.mjs +160 -0
  89. package/dist/src/project/configuration_validation.d.mts +22 -0
  90. package/dist/src/project/configuration_validation.mjs +136 -0
  91. package/dist/src/project/project_detection.d.mts +14 -0
  92. package/dist/src/project/project_detection.mjs +47 -0
  93. package/dist/src/runtime/lifecycle_engine.d.mts +63 -0
  94. package/dist/src/runtime/lifecycle_engine.mjs +114 -0
  95. package/dist/src/runtime/plan.mjs +2 -1
  96. package/dist/src/targets/postgresql.mjs +142 -163
  97. package/dist/src/targets/supabase.mjs +142 -174
  98. package/docs/architecture.md +118 -3
  99. package/docs/releasing.md +8 -3
  100. package/docs/typescript.md +57 -10
  101. package/package.json +10 -9
  102. package/types/configuration.d.mts +5 -164
@@ -0,0 +1,7 @@
1
+ /** Create and verify the owner-only secret used by deterministic privacy rules. */
2
+ export declare const PRIVACY_KEY_BYTES = 32;
3
+ export declare const createPrivacyKey: (path: string) => Promise<{
4
+ fingerprint: string;
5
+ }>;
6
+ export declare const readPrivacyKey: (path: string) => Promise<Buffer>;
7
+ export declare const assertPrivacyKeyFingerprint: (key: Uint8Array, expected: unknown) => void;
@@ -0,0 +1,42 @@
1
+ /** Create and verify the owner-only secret used by deterministic privacy rules. */
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ export const PRIVACY_KEY_BYTES = 32;
6
+ const HEX_64 = /^[a-f0-9]{64}$/u;
7
+ export const createPrivacyKey = async (path) => {
8
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
9
+ const key = randomBytes(PRIVACY_KEY_BYTES);
10
+ const handle = await open(path, "wx", 0o600);
11
+ try {
12
+ await handle.writeFile(`${key.toString("base64")}\n`);
13
+ await handle.sync();
14
+ }
15
+ finally {
16
+ await handle.close();
17
+ }
18
+ await chmod(path, 0o600);
19
+ return { fingerprint: createHash("sha256").update(key).digest("hex") };
20
+ };
21
+ export const readPrivacyKey = async (path) => {
22
+ const details = await stat(path);
23
+ if (!details.isFile() || (details.mode & 0o077) !== 0) {
24
+ throw new Error("Privacy key must be an owner-only regular file.");
25
+ }
26
+ const encoded = (await readFile(path, "utf8")).trim();
27
+ const key = Buffer.from(encoded, "base64");
28
+ if (key.length !== PRIVACY_KEY_BYTES ||
29
+ !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
30
+ throw new Error("Privacy key file is invalid.");
31
+ }
32
+ return key;
33
+ };
34
+ export const assertPrivacyKeyFingerprint = (key, expected) => {
35
+ if (typeof expected !== "string" || !HEX_64.test(expected)) {
36
+ throw new Error("Privacy key fingerprint is invalid.");
37
+ }
38
+ const actual = createHash("sha256").update(key).digest("hex");
39
+ if (actual !== expected) {
40
+ throw new Error("Privacy key does not match the reviewed baseline receipt.");
41
+ }
42
+ };
@@ -0,0 +1,7 @@
1
+ /** Resolve reviewed bindings and execute the dependency-aware row privacy pipeline. */
2
+ import type { PrivacyEngine } from "./privacy_contract.mjs";
3
+ export declare const createPrivacyEngine: ({ policy, key, environment, }: {
4
+ policy: unknown;
5
+ key: Uint8Array | string;
6
+ environment?: NodeJS.ProcessEnv;
7
+ }) => Readonly<PrivacyEngine>;
@@ -0,0 +1,127 @@
1
+ /** Resolve reviewed bindings and execute the dependency-aware row privacy pipeline. */
2
+ import { createHash } from "node:crypto";
3
+ import { PRIVACY_KEY_BYTES } from "./privacy_key.mjs";
4
+ import { validateExecutablePrivacyPolicy } from "./privacy_policy.mjs";
5
+ import { executePrivacyDeclaration } from "./privacy_rule_registry.mjs";
6
+ import { remapPrivacyPath } from "./privacy_transforms.mjs";
7
+ import { IDENTIFIER, isPrivacyObject } from "./privacy_validation_shared.mjs";
8
+ export const createPrivacyEngine = ({ policy, key, environment = process.env, }) => {
9
+ const normalized = validateExecutablePrivacyPolicy(policy);
10
+ const secret = Buffer.isBuffer(key) ? key : Buffer.from(key ?? "");
11
+ if (secret.length < PRIVACY_KEY_BYTES) {
12
+ throw new Error(`Privacy key must contain at least ${PRIVACY_KEY_BYTES} bytes.`);
13
+ }
14
+ const bindingValues = new Map(Object.entries(normalized.bindings).map(([name, binding]) => {
15
+ const value = environment[binding.environmentVariable];
16
+ if (typeof value !== "string" || !value) {
17
+ throw new Error(`Privacy binding ${name} requires ${binding.environmentVariable}.`);
18
+ }
19
+ const receipt = createHash("sha256").update(value).digest("hex");
20
+ if (receipt !== binding.approvedValueSha256) {
21
+ throw new Error(`Privacy binding ${name} does not match its review receipt.`);
22
+ }
23
+ return [name, value];
24
+ }));
25
+ const tables = new Map(normalized.tables.map((table) => [
26
+ table.schema === "public" ? table.name : `${table.schema}.${table.name}`,
27
+ table,
28
+ ]));
29
+ return Object.freeze({
30
+ keyFingerprint: createHash("sha256").update(secret).digest("hex"),
31
+ remapPath({ mapping: mappingName, value, }) {
32
+ const mapping = normalized.pathMappings[mappingName];
33
+ if (!mapping) {
34
+ throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
35
+ }
36
+ return remapPrivacyPath({
37
+ key: secret,
38
+ mapping,
39
+ bindingValue: bindingValues.get(mapping.binding),
40
+ value,
41
+ label: `Privacy path mapping ${mappingName}`,
42
+ });
43
+ },
44
+ sanitize(record) {
45
+ if (!isPrivacyObject(record) ||
46
+ typeof (record.schema ?? "public") !== "string" ||
47
+ !IDENTIFIER.test(String(record.schema ?? "public")) ||
48
+ typeof record.table !== "string" ||
49
+ !IDENTIFIER.test(record.table) ||
50
+ !isPrivacyObject(record.row)) {
51
+ throw new Error("Source record has an invalid privacy-engine shape.");
52
+ }
53
+ const schema = String(record.schema ?? "public");
54
+ const rowSource = record.row;
55
+ const relation = schema === "public" ? record.table : `${schema}.${record.table}`;
56
+ const table = tables.get(relation);
57
+ if (!table) {
58
+ throw new Error(`Privacy policy does not classify table ${relation}.`);
59
+ }
60
+ if (table.sourceRows === "EXCLUDE")
61
+ return null;
62
+ const actual = Object.keys(rowSource).sort();
63
+ const expected = table.columns.map((column) => column.name).sort();
64
+ if (actual.join("\0") !== expected.join("\0")) {
65
+ throw new Error(`Source table ${relation} contains an unclassified or missing column.`);
66
+ }
67
+ let ownerApproved;
68
+ if (table.ownerBinding) {
69
+ const approvedValue = bindingValues.get(table.ownerBinding.binding);
70
+ const owners = table.ownerBinding.columns.map((column) => rowSource[column]);
71
+ for (const owner of owners) {
72
+ if (owner === null) {
73
+ if (table.ownerBinding.nullBehavior !== "otherwise") {
74
+ throw new Error(`${relation} has an invalid owner binding value.`);
75
+ }
76
+ continue;
77
+ }
78
+ if (!["string", "number"].includes(typeof owner) ||
79
+ owner === "" ||
80
+ (typeof owner === "number" && !Number.isFinite(owner))) {
81
+ throw new Error(`${relation} has an invalid owner binding value.`);
82
+ }
83
+ }
84
+ ownerApproved = owners.some((owner) => owner !== null && String(owner) === approvedValue);
85
+ }
86
+ const sanitizedColumns = new Map();
87
+ const resolvingColumns = new Set();
88
+ const sanitizeColumn = (columnName) => {
89
+ if (sanitizedColumns.has(columnName)) {
90
+ return sanitizedColumns.get(columnName);
91
+ }
92
+ if (resolvingColumns.has(columnName)) {
93
+ throw new Error(`${relation} contains a cyclic digest dependency.`);
94
+ }
95
+ const column = table.columns.find((entry) => entry.name === columnName);
96
+ if (!column) {
97
+ throw new Error(`${relation} digest references unknown column ${columnName}.`);
98
+ }
99
+ resolvingColumns.add(columnName);
100
+ try {
101
+ const transformed = executePrivacyDeclaration({
102
+ declaration: column,
103
+ value: rowSource[column.name],
104
+ key: secret,
105
+ path: `${relation}.${column.name}`,
106
+ ownerApproved,
107
+ pathMappings: normalized.pathMappings,
108
+ bindingValues,
109
+ resolveInput: sanitizeColumn,
110
+ });
111
+ sanitizedColumns.set(columnName, transformed);
112
+ return transformed;
113
+ }
114
+ finally {
115
+ resolvingColumns.delete(columnName);
116
+ }
117
+ };
118
+ const row = Object.fromEntries(table.columns.flatMap((column) => {
119
+ const transformed = sanitizeColumn(column.name);
120
+ return transformed === undefined ? [] : [[column.name, transformed]];
121
+ }));
122
+ return schema === "public"
123
+ ? { table: record.table, row }
124
+ : { schema, table: record.table, row };
125
+ },
126
+ });
127
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Validate and normalize the complete reviewed privacy policy version 2.
3
+ */
4
+ import type { ExecutablePrivacyPolicy } from "./privacy_contract.mjs";
5
+ export type { ExecutablePrivacyPolicy, PrivacyColumn, PrivacyEngine, PrivacyForeignKey, PrivacySourceRecord, PrivacyTable, } from "./privacy_contract.mjs";
6
+ export declare const validateExecutablePrivacyPolicy: (policy: unknown) => ExecutablePrivacyPolicy;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Validate and normalize the complete reviewed privacy policy version 2.
3
+ */
4
+ import { validatePrivacyDependencyGraph } from "./privacy_dependency_graph.mjs";
5
+ import { validatePrivacyRecipe } from "./privacy_recipe_validation.mjs";
6
+ import { ACTIONS, BINDING_NAME, DEFAULT_PATH_MAXIMUM_BYTES, DEFAULT_PATH_MAXIMUM_SEGMENTS, ENVIRONMENT_KEY, HEX_64, assertKnownKeys as knownKeys, isPrivacyObject as isObject, validateIdentifier as identifier, validateNonEmpty as nonEmpty, validatePositiveInteger as positiveInteger, } from "./privacy_validation_shared.mjs";
7
+ export const validateExecutablePrivacyPolicy = (policy) => {
8
+ if (!isObject(policy))
9
+ throw new Error("Privacy policy must be an object.");
10
+ knownKeys(policy, [
11
+ "policyVersion",
12
+ "draft",
13
+ "migrationCutoff",
14
+ "bindings",
15
+ "pathMappings",
16
+ "tables",
17
+ ], "policy");
18
+ if (policy.policyVersion !== 2) {
19
+ throw new Error("Executable privacy policies must use policyVersion 2.");
20
+ }
21
+ if (policy.draft === true) {
22
+ throw new Error("The privacy policy is still a REVIEW REQUIRED draft.");
23
+ }
24
+ if (typeof policy.migrationCutoff !== "string" ||
25
+ !/^\d{14}$/u.test(policy.migrationCutoff)) {
26
+ throw new Error("Privacy policy migrationCutoff must be a timestamp version.");
27
+ }
28
+ if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
29
+ throw new Error("Privacy policy must classify at least one table.");
30
+ }
31
+ if (policy.bindings !== undefined && !isObject(policy.bindings)) {
32
+ throw new Error("policy.bindings must be an object.");
33
+ }
34
+ const bindings = Object.fromEntries(Object.entries(policy.bindings ?? {}).map(([name, binding]) => {
35
+ if (!BINDING_NAME.test(name) || !isObject(binding)) {
36
+ throw new Error("policy.bindings contains an invalid binding.");
37
+ }
38
+ knownKeys(binding, ["environmentVariable", "approvedValueSha256"], `policy.bindings.${name}`);
39
+ if (typeof binding.environmentVariable !== "string" ||
40
+ !ENVIRONMENT_KEY.test(binding.environmentVariable)) {
41
+ throw new Error(`policy.bindings.${name}.environmentVariable is invalid.`);
42
+ }
43
+ if (typeof binding.approvedValueSha256 !== "string" ||
44
+ !HEX_64.test(binding.approvedValueSha256)) {
45
+ throw new Error(`policy.bindings.${name}.approvedValueSha256 must be a lowercase SHA-256 receipt.`);
46
+ }
47
+ return [
48
+ name,
49
+ Object.freeze({
50
+ environmentVariable: binding.environmentVariable,
51
+ approvedValueSha256: binding.approvedValueSha256,
52
+ }),
53
+ ];
54
+ }));
55
+ if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
56
+ throw new Error("policy.pathMappings must be an object.");
57
+ }
58
+ const pathMappings = Object.fromEntries(Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
59
+ const label = `policy.pathMappings.${name}`;
60
+ if (!BINDING_NAME.test(name) || !isObject(mapping)) {
61
+ throw new Error("policy.pathMappings contains an invalid mapping.");
62
+ }
63
+ knownKeys(mapping, [
64
+ "binding",
65
+ "format",
66
+ "namespace",
67
+ "maxLength",
68
+ "maximumBytes",
69
+ "maximumSegments",
70
+ ], label);
71
+ if (typeof mapping.binding !== "string" ||
72
+ !Object.hasOwn(bindings, mapping.binding)) {
73
+ throw new Error(`${label}.binding references an unknown binding.`);
74
+ }
75
+ if (typeof mapping.format !== "string" ||
76
+ !["uuid", "text"].includes(mapping.format)) {
77
+ throw new Error(`${label}.format must be uuid or text.`);
78
+ }
79
+ const normalized = {
80
+ binding: mapping.binding,
81
+ format: mapping.format,
82
+ namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
83
+ maximumBytes: positiveInteger(mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES, `${label}.maximumBytes`, 16_384),
84
+ maximumSegments: positiveInteger(mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS, `${label}.maximumSegments`, 256),
85
+ };
86
+ if (mapping.format === "text") {
87
+ return [
88
+ name,
89
+ Object.freeze({
90
+ ...normalized,
91
+ maxLength: positiveInteger(mapping.maxLength, `${label}.maxLength`, 1_024),
92
+ }),
93
+ ];
94
+ }
95
+ else if (mapping.maxLength !== undefined) {
96
+ throw new Error(`${label}.maxLength is supported only for text.`);
97
+ }
98
+ return [name, Object.freeze(normalized)];
99
+ }));
100
+ const names = new Set();
101
+ const tables = policy.tables.map((table, tableIndex) => {
102
+ const label = `policy.tables[${tableIndex}]`;
103
+ if (!isObject(table))
104
+ throw new Error(`${label} must be an object.`);
105
+ knownKeys(table, ["schema", "name", "sourceRows", "ownerBinding", "columns"], label);
106
+ const schema = identifier(table.schema ?? "public", `${label}.schema`);
107
+ const name = identifier(table.name, `${label}.name`);
108
+ const key = schema === "public" ? name : `${schema}.${name}`;
109
+ if (names.has(key))
110
+ throw new Error(`Privacy policy duplicates table ${key}.`);
111
+ names.add(key);
112
+ if (typeof table.sourceRows !== "string" ||
113
+ !["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
114
+ throw new Error(`${label}.sourceRows is unsupported.`);
115
+ }
116
+ if (!Array.isArray(table.columns) || table.columns.length === 0) {
117
+ throw new Error(`${label}.columns must not be empty.`);
118
+ }
119
+ const sourceRows = table.sourceRows;
120
+ const columnNames = new Set();
121
+ const columns = table.columns.map((column, columnIndex) => {
122
+ const columnLabel = `${label}.columns[${columnIndex}]`;
123
+ if (!isObject(column))
124
+ throw new Error(`${columnLabel} must be an object.`);
125
+ knownKeys(column, ["name", "action", "recipe", "generated", "identity", "foreignKey"], columnLabel);
126
+ const columnName = identifier(column.name, `${columnLabel}.name`);
127
+ if (columnNames.has(columnName)) {
128
+ throw new Error(`Privacy policy duplicates column ${name}.${columnName}.`);
129
+ }
130
+ columnNames.add(columnName);
131
+ const action = String(column.action ?? "").toUpperCase();
132
+ if (!ACTIONS.has(action))
133
+ throw new Error(`${columnLabel}.action is unsupported.`);
134
+ if (typeof column.generated !== "string" ||
135
+ !["ALWAYS", "NEVER"].includes(column.generated)) {
136
+ throw new Error(`${columnLabel}.generated must be ALWAYS or NEVER.`);
137
+ }
138
+ if (typeof column.identity !== "string" ||
139
+ !["YES", "NO"].includes(column.identity)) {
140
+ throw new Error(`${columnLabel}.identity must be YES or NO.`);
141
+ }
142
+ if (column.foreignKey !== null) {
143
+ if (!isObject(column.foreignKey)) {
144
+ throw new Error(`${columnLabel}.foreignKey must be null or an object.`);
145
+ }
146
+ knownKeys(column.foreignKey, ["schema", "table", "column"], `${columnLabel}.foreignKey`);
147
+ identifier(column.foreignKey.schema, `${columnLabel}.foreignKey.schema`);
148
+ identifier(column.foreignKey.table, `${columnLabel}.foreignKey.table`);
149
+ identifier(column.foreignKey.column, `${columnLabel}.foreignKey.column`);
150
+ }
151
+ return Object.freeze({
152
+ ...column,
153
+ name: columnName,
154
+ action: action,
155
+ recipe: validatePrivacyRecipe(action, column.recipe, columnLabel),
156
+ generated: column.generated,
157
+ identity: column.identity,
158
+ foreignKey: column.foreignKey,
159
+ });
160
+ });
161
+ let ownerBinding = null;
162
+ if (table.ownerBinding !== undefined && table.ownerBinding !== null) {
163
+ if (!isObject(table.ownerBinding)) {
164
+ throw new Error(`${label}.ownerBinding must be an object.`);
165
+ }
166
+ knownKeys(table.ownerBinding, ["binding", "column", "columns", "match", "nullBehavior"], `${label}.ownerBinding`);
167
+ const binding = nonEmpty(table.ownerBinding.binding, `${label}.ownerBinding.binding`);
168
+ const hasColumn = table.ownerBinding.column !== undefined;
169
+ const hasColumns = table.ownerBinding.columns !== undefined;
170
+ if (hasColumn === hasColumns) {
171
+ throw new Error(`${label}.ownerBinding must declare exactly one of column or columns.`);
172
+ }
173
+ let ownerColumns;
174
+ if (hasColumn) {
175
+ if (table.ownerBinding.match !== undefined) {
176
+ throw new Error(`${label}.ownerBinding.match is supported only with columns.`);
177
+ }
178
+ ownerColumns = [
179
+ identifier(table.ownerBinding.column, `${label}.ownerBinding.column`),
180
+ ];
181
+ }
182
+ else {
183
+ if (!Array.isArray(table.ownerBinding.columns) ||
184
+ table.ownerBinding.columns.length === 0 ||
185
+ table.ownerBinding.columns.length > 16) {
186
+ throw new Error(`${label}.ownerBinding.columns must contain 1 through 16 columns.`);
187
+ }
188
+ if (table.ownerBinding.match !== "any") {
189
+ throw new Error(`${label}.ownerBinding.match must be any when columns are used.`);
190
+ }
191
+ ownerColumns = table.ownerBinding.columns.map((column, index) => identifier(column, `${label}.ownerBinding.columns[${index}]`));
192
+ if (new Set(ownerColumns).size !== ownerColumns.length) {
193
+ throw new Error(`${label}.ownerBinding.columns must not contain duplicates.`);
194
+ }
195
+ }
196
+ if (table.ownerBinding.nullBehavior !== undefined &&
197
+ table.ownerBinding.nullBehavior !== "otherwise") {
198
+ throw new Error(`${label}.ownerBinding.nullBehavior must be otherwise when declared.`);
199
+ }
200
+ if (!Object.hasOwn(bindings, binding)) {
201
+ throw new Error(`${label}.ownerBinding references an unknown binding.`);
202
+ }
203
+ for (const column of ownerColumns) {
204
+ if (!columnNames.has(column)) {
205
+ throw new Error(`${label}.ownerBinding references an unknown column.`);
206
+ }
207
+ const ownerColumn = columns.find((entry) => entry.name === column);
208
+ if (!ownerColumn || ownerColumn.action !== "PSEUDONYMIZE") {
209
+ throw new Error(`${label}.ownerBinding columns must be explicitly pseudonymized.`);
210
+ }
211
+ }
212
+ ownerBinding = Object.freeze({
213
+ binding,
214
+ columns: Object.freeze(ownerColumns),
215
+ match: "any",
216
+ ...(table.ownerBinding.nullBehavior === undefined
217
+ ? {}
218
+ : { nullBehavior: table.ownerBinding.nullBehavior }),
219
+ });
220
+ }
221
+ return Object.freeze({
222
+ schema,
223
+ name,
224
+ sourceRows,
225
+ ownerBinding,
226
+ columns: Object.freeze(columns),
227
+ });
228
+ });
229
+ validatePrivacyDependencyGraph({ tables, bindings, pathMappings });
230
+ return Object.freeze({
231
+ policyVersion: 2,
232
+ migrationCutoff: policy.migrationCutoff,
233
+ bindings: Object.freeze(bindings),
234
+ pathMappings: Object.freeze(pathMappings),
235
+ tables: Object.freeze(tables),
236
+ });
237
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Validate and normalize reviewed privacy recipes for policy version 2.
3
+ */
4
+ import type { NormalizedRecipe, PrivacyAction } from "./privacy_contract.mjs";
5
+ export declare const validatePrivacyRecipe: (action: PrivacyAction, recipe: unknown, label: string, depth?: number) => NormalizedRecipe;