@rehearsal-db/core 0.1.0-beta.9 → 0.1.0-rc.2

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 (247) hide show
  1. package/CHANGELOG.md +319 -1
  2. package/COMPATIBILITY.md +15 -0
  3. package/README.md +73 -16
  4. package/dist/package.json +100 -0
  5. package/dist/scripts/install/create_root_config.d.mts +3 -0
  6. package/dist/scripts/install/create_root_config.mjs +13 -0
  7. package/dist/scripts/runtime/manage_database.d.mts +6 -0
  8. package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
  9. package/dist/src/application/interactive_task.d.mts +30 -0
  10. package/dist/src/application/interactive_task.mjs +83 -0
  11. package/dist/src/application/session.d.mts +97 -0
  12. package/dist/src/application/session.mjs +380 -0
  13. package/dist/src/baseline/artifact.d.mts +119 -0
  14. package/dist/src/baseline/artifact.mjs +522 -0
  15. package/dist/src/baseline/builder.d.mts +19 -0
  16. package/dist/src/baseline/builder.mjs +142 -0
  17. package/dist/src/baseline/input_discovery.d.mts +13 -0
  18. package/dist/src/baseline/input_discovery.mjs +149 -0
  19. package/dist/src/baseline/policy_review.d.mts +56 -0
  20. package/dist/src/baseline/policy_review.mjs +73 -0
  21. package/dist/src/baseline/preparation.d.mts +63 -0
  22. package/dist/src/baseline/preparation.mjs +295 -0
  23. package/dist/src/baseline/privacy_contract.d.mts +147 -0
  24. package/dist/src/baseline/privacy_contract.mjs +2 -0
  25. package/dist/src/baseline/privacy_dependency_graph.d.mts +7 -0
  26. package/dist/src/baseline/privacy_dependency_graph.mjs +110 -0
  27. package/dist/src/baseline/privacy_engine.d.mts +5 -0
  28. package/dist/src/baseline/privacy_engine.mjs +4 -0
  29. package/dist/src/baseline/privacy_key.d.mts +7 -0
  30. package/dist/src/baseline/privacy_key.mjs +42 -0
  31. package/dist/src/baseline/privacy_pipeline.d.mts +7 -0
  32. package/dist/src/baseline/privacy_pipeline.mjs +127 -0
  33. package/dist/src/baseline/privacy_policy.d.mts +6 -0
  34. package/dist/src/baseline/privacy_policy.mjs +237 -0
  35. package/dist/src/baseline/privacy_recipe_validation.d.mts +5 -0
  36. package/dist/src/baseline/privacy_recipe_validation.mjs +289 -0
  37. package/dist/src/baseline/privacy_rule_registry.d.mts +18 -0
  38. package/dist/src/baseline/privacy_rule_registry.mjs +143 -0
  39. package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
  40. package/dist/src/baseline/privacy_structured_json.mjs +464 -0
  41. package/dist/src/baseline/privacy_transforms.d.mts +18 -0
  42. package/dist/src/baseline/privacy_transforms.mjs +142 -0
  43. package/dist/src/baseline/privacy_validation_shared.d.mts +31 -0
  44. package/dist/src/baseline/privacy_validation_shared.mjs +127 -0
  45. package/dist/src/baseline/sanitization_policy.d.mts +69 -0
  46. package/dist/src/baseline/sanitization_policy.mjs +227 -0
  47. package/dist/src/baseline/schema_snapshot.d.mts +19 -0
  48. package/dist/src/baseline/schema_snapshot.mjs +79 -0
  49. package/dist/src/cli/application_command_handlers.d.mts +8 -0
  50. package/dist/src/cli/application_command_handlers.mjs +52 -0
  51. package/dist/src/cli/arguments.d.mts +38 -0
  52. package/dist/src/cli/arguments.mjs +130 -0
  53. package/dist/src/cli/basic_command_handlers.d.mts +4 -0
  54. package/dist/src/cli/basic_command_handlers.mjs +57 -0
  55. package/dist/src/cli/cli_commands.d.mts +14 -0
  56. package/dist/src/cli/cli_commands.mjs +34 -0
  57. package/dist/src/cli/command_contract.d.mts +57 -0
  58. package/dist/src/cli/command_contract.mjs +39 -0
  59. package/dist/src/cli/command_output.d.mts +12 -0
  60. package/dist/src/cli/command_output.mjs +20 -0
  61. package/dist/src/cli/command_registry.d.mts +7 -0
  62. package/dist/src/cli/command_registry.mjs +14 -0
  63. package/dist/src/cli/guided.d.mts +53 -0
  64. package/dist/src/cli/guided.mjs +727 -0
  65. package/dist/src/cli/identity_command_handlers.d.mts +12 -0
  66. package/dist/src/cli/identity_command_handlers.mjs +239 -0
  67. package/dist/src/cli/identity_renderer.d.mts +8 -0
  68. package/dist/src/cli/identity_renderer.mjs +33 -0
  69. package/dist/src/cli/init_command.d.mts +21 -0
  70. package/dist/src/cli/init_command.mjs +90 -0
  71. package/dist/src/cli/inspection_command_handlers.d.mts +3 -0
  72. package/dist/src/cli/inspection_command_handlers.mjs +79 -0
  73. package/dist/src/cli/project_command_handlers.d.mts +11 -0
  74. package/dist/src/cli/project_command_handlers.mjs +92 -0
  75. package/dist/src/cli/project_commands.d.mts +283 -0
  76. package/dist/src/cli/project_commands.mjs +192 -0
  77. package/dist/src/cli/rehearsal.d.mts +7 -0
  78. package/dist/src/cli/rehearsal.mjs +190 -0
  79. package/dist/src/cli/renderers.d.mts +299 -0
  80. package/dist/src/cli/renderers.mjs +554 -0
  81. package/dist/src/cli/runtime_action_handlers.d.mts +12 -0
  82. package/dist/src/cli/runtime_action_handlers.mjs +201 -0
  83. package/dist/src/cli/runtime_commands.d.mts +294 -0
  84. package/dist/src/cli/runtime_commands.mjs +619 -0
  85. package/dist/src/cli/source_command_handlers.d.mts +8 -0
  86. package/dist/src/cli/source_command_handlers.mjs +62 -0
  87. package/dist/src/cli/source_commands.d.mts +436 -0
  88. package/dist/src/cli/source_commands.mjs +301 -0
  89. package/dist/src/cli/terminal.d.mts +59 -0
  90. package/dist/src/cli/terminal.mjs +230 -0
  91. package/dist/src/identity/claim.d.mts +5 -0
  92. package/dist/src/identity/claim.mjs +4 -0
  93. package/dist/src/identity/claim_contract.d.mts +135 -0
  94. package/dist/src/identity/claim_contract.mjs +6 -0
  95. package/dist/src/identity/claim_executor.d.mts +10 -0
  96. package/dist/src/identity/claim_executor.mjs +358 -0
  97. package/dist/src/identity/claim_identity_lookup.d.mts +23 -0
  98. package/dist/src/identity/claim_identity_lookup.mjs +134 -0
  99. package/dist/src/identity/claim_plan.d.mts +8 -0
  100. package/dist/src/identity/claim_plan.mjs +61 -0
  101. package/dist/src/identity/claim_policy.d.mts +3 -0
  102. package/dist/src/identity/claim_policy.mjs +377 -0
  103. package/dist/src/identity/claim_sql.d.mts +10 -0
  104. package/dist/src/identity/claim_sql.mjs +46 -0
  105. package/dist/src/identity/claim_verification.d.mts +13 -0
  106. package/dist/src/identity/claim_verification.mjs +35 -0
  107. package/dist/src/identity/connect.d.mts +30 -0
  108. package/dist/src/identity/connect.mjs +111 -0
  109. package/dist/src/identity/provider_configuration.d.mts +37 -0
  110. package/dist/src/identity/provider_configuration.mjs +132 -0
  111. package/dist/src/identity/storage.d.mts +21 -0
  112. package/dist/src/identity/storage.mjs +122 -0
  113. package/dist/src/index.d.mts +4 -0
  114. package/dist/src/index.mjs +3 -0
  115. package/dist/src/project/configuration.d.mts +13 -0
  116. package/dist/src/project/configuration.mjs +9 -0
  117. package/dist/src/project/configuration_contract.d.mts +125 -0
  118. package/dist/src/project/configuration_contract.mjs +27 -0
  119. package/dist/src/project/configuration_discovery.d.mts +7 -0
  120. package/dist/src/project/configuration_discovery.mjs +37 -0
  121. package/dist/src/project/configuration_normalization.d.mts +107 -0
  122. package/dist/src/project/configuration_normalization.mjs +302 -0
  123. package/dist/src/project/configuration_rendering.d.mts +14 -0
  124. package/dist/src/project/configuration_rendering.mjs +228 -0
  125. package/dist/src/project/configuration_resolution.d.mts +125 -0
  126. package/dist/src/project/configuration_resolution.mjs +81 -0
  127. package/dist/src/project/configuration_schema.d.mts +22 -0
  128. package/dist/src/project/configuration_schema.mjs +160 -0
  129. package/dist/src/project/configuration_validation.d.mts +22 -0
  130. package/dist/src/project/configuration_validation.mjs +136 -0
  131. package/dist/src/project/install_scaffold.d.mts +42 -0
  132. package/dist/src/project/install_scaffold.mjs +263 -0
  133. package/dist/src/project/project_detection.d.mts +14 -0
  134. package/dist/src/project/project_detection.mjs +47 -0
  135. package/dist/src/project/setup.d.mts +87 -0
  136. package/dist/src/project/setup.mjs +414 -0
  137. package/dist/src/project/support_report.d.mts +88 -0
  138. package/dist/src/project/support_report.mjs +83 -0
  139. package/dist/src/runtime/cleanup.d.mts +85 -0
  140. package/dist/src/runtime/cleanup.mjs +311 -0
  141. package/dist/src/runtime/lifecycle_engine.d.mts +63 -0
  142. package/dist/src/runtime/lifecycle_engine.mjs +114 -0
  143. package/dist/src/runtime/migration_history.d.mts +51 -0
  144. package/dist/src/runtime/migration_history.mjs +170 -0
  145. package/dist/src/runtime/plan.d.mts +134 -0
  146. package/dist/src/runtime/plan.mjs +640 -0
  147. package/dist/src/runtime/policy.d.mts +66 -0
  148. package/dist/src/runtime/policy.mjs +369 -0
  149. package/dist/src/runtime/restore.d.mts +22 -0
  150. package/dist/src/runtime/restore.mjs +276 -0
  151. package/dist/src/runtime/service_environment.d.mts +11 -0
  152. package/dist/src/runtime/service_environment.mjs +58 -0
  153. package/dist/src/runtime/topology.d.mts +51 -0
  154. package/dist/src/runtime/topology.mjs +128 -0
  155. package/dist/src/shared/diagnostics.d.mts +97 -0
  156. package/dist/src/shared/diagnostics.mjs +170 -0
  157. package/dist/src/shared/human_output.d.mts +2 -0
  158. package/{src → dist/src}/shared/human_output.mjs +1 -3
  159. package/dist/src/shared/node_runtime.d.mts +14 -0
  160. package/dist/src/shared/node_runtime.mjs +25 -0
  161. package/dist/src/shared/operation_guard.d.mts +22 -0
  162. package/dist/src/shared/operation_guard.mjs +150 -0
  163. package/dist/src/shared/process_environment.d.mts +15 -0
  164. package/dist/src/shared/process_environment.mjs +50 -0
  165. package/dist/src/source/access.d.mts +165 -0
  166. package/dist/src/source/access.mjs +602 -0
  167. package/dist/src/source/asset_transfer.d.mts +27 -0
  168. package/dist/src/source/asset_transfer.mjs +202 -0
  169. package/dist/src/source/baseline.d.mts +66 -0
  170. package/dist/src/source/baseline.mjs +372 -0
  171. package/dist/src/source/external_reader.d.mts +42 -0
  172. package/dist/src/source/external_reader.mjs +278 -0
  173. package/dist/src/source/postgresql_access.d.mts +86 -0
  174. package/dist/src/source/postgresql_access.mjs +454 -0
  175. package/dist/src/source/privacy_paths.d.mts +10 -0
  176. package/dist/src/source/privacy_paths.mjs +27 -0
  177. package/dist/src/targets/driver.d.mts +2 -0
  178. package/dist/src/targets/driver.mjs +13 -0
  179. package/dist/src/targets/postgresql.d.mts +5 -0
  180. package/dist/src/targets/postgresql.mjs +620 -0
  181. package/dist/src/targets/supabase.d.mts +8 -0
  182. package/dist/src/targets/supabase.mjs +629 -0
  183. package/dist/src/targets/supabase_environment.d.mts +54 -0
  184. package/dist/src/targets/supabase_environment.mjs +184 -0
  185. package/dist/src/targets/target.d.mts +33 -0
  186. package/dist/src/targets/target.mjs +39 -0
  187. package/docs/README.md +35 -16
  188. package/docs/architecture.md +135 -10
  189. package/docs/commands.md +53 -15
  190. package/docs/configuration.md +186 -31
  191. package/docs/getting-started.md +66 -9
  192. package/docs/index.md +67 -0
  193. package/docs/production-source.md +175 -17
  194. package/docs/releasing.md +24 -12
  195. package/docs/roadmap.md +3 -2
  196. package/docs/runtime-lifecycle.md +99 -0
  197. package/docs/runtime-policies.md +80 -7
  198. package/docs/sanitization.md +572 -4
  199. package/docs/security-model.md +8 -0
  200. package/docs/stability.md +70 -0
  201. package/docs/standalone-workflow.md +36 -13
  202. package/docs/troubleshooting.md +82 -10
  203. package/docs/tutorial.md +7 -1
  204. package/docs/typescript.md +134 -0
  205. package/package.json +44 -29
  206. package/types/configuration.d.mts +5 -0
  207. package/src/README.md +0 -17
  208. package/src/application/session.mjs +0 -313
  209. package/src/baseline/artifact.mjs +0 -661
  210. package/src/baseline/builder.mjs +0 -164
  211. package/src/baseline/input_discovery.mjs +0 -155
  212. package/src/baseline/policy_review.mjs +0 -92
  213. package/src/baseline/preparation.mjs +0 -323
  214. package/src/baseline/privacy_engine.mjs +0 -413
  215. package/src/baseline/sanitization_policy.mjs +0 -299
  216. package/src/baseline/schema_snapshot.mjs +0 -113
  217. package/src/cli/arguments.mjs +0 -120
  218. package/src/cli/guided.mjs +0 -807
  219. package/src/cli/rehearsal.mjs +0 -933
  220. package/src/cli/renderers.mjs +0 -584
  221. package/src/cli/runtime_commands.mjs +0 -553
  222. package/src/cli/source_commands.mjs +0 -326
  223. package/src/cli/terminal.mjs +0 -275
  224. package/src/identity/claim.mjs +0 -975
  225. package/src/identity/storage.mjs +0 -165
  226. package/src/project/configuration.d.mts +0 -153
  227. package/src/project/configuration.mjs +0 -1197
  228. package/src/project/setup.mjs +0 -379
  229. package/src/project/support_report.mjs +0 -105
  230. package/src/runtime/cleanup.mjs +0 -381
  231. package/src/runtime/migration_history.mjs +0 -220
  232. package/src/runtime/plan.mjs +0 -685
  233. package/src/runtime/policy.mjs +0 -438
  234. package/src/runtime/restore.mjs +0 -329
  235. package/src/runtime/service_environment.mjs +0 -82
  236. package/src/runtime/topology.mjs +0 -177
  237. package/src/shared/diagnostics.mjs +0 -199
  238. package/src/shared/operation_guard.mjs +0 -162
  239. package/src/shared/process_environment.mjs +0 -68
  240. package/src/source/access.mjs +0 -578
  241. package/src/source/asset_transfer.mjs +0 -177
  242. package/src/source/baseline.mjs +0 -446
  243. package/src/source/postgresql_access.mjs +0 -480
  244. package/src/targets/postgresql.mjs +0 -808
  245. package/src/targets/supabase.mjs +0 -830
  246. package/src/targets/supabase_environment.mjs +0 -229
  247. package/src/targets/target.mjs +0 -66
@@ -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;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Validate and normalize reviewed privacy recipes for policy version 2.
3
+ */
4
+ import { validateStructuredJsonRecipe } from "./privacy_structured_json.mjs";
5
+ import { ACTIONS, BINDING_NAME, DATE_REPRESENTATIONS, DERIVATION_KINDS, GTIN_LENGTHS, HEX_LENGTHS, MAXIMUM_ENUM_VALUES, MAXIMUM_POLICY_DEPTH, MAXIMUM_TEXT_SUBSTITUTION_BYTES, MAXIMUM_URL_LENGTH, PSEUDONYM_FORMATS, URL_PATH_PREFIX, URL_TOKEN_LENGTH, assertKnownKeys as knownKeys, isPrivacyObject as isObject, validateHttpsLoopbackOrigin as httpsLoopbackOrigin, validateIdentifier as identifier, validateNonEmpty as nonEmpty, validatePositiveInteger as positiveInteger, validateReviewedScalar as reviewedScalar, } from "./privacy_validation_shared.mjs";
6
+ const validateNestedDeclaration = (declaration, label, { allowRequired = false, depth = 0, } = {}) => {
7
+ if (!isObject(declaration))
8
+ throw new Error(`${label} must be an object.`);
9
+ knownKeys(declaration, allowRequired ? ["action", "recipe", "required"] : ["action", "recipe"], label);
10
+ if (allowRequired &&
11
+ declaration.required !== undefined &&
12
+ typeof declaration.required !== "boolean") {
13
+ throw new Error(`${label}.required must be true or false.`);
14
+ }
15
+ const action = String(declaration.action ?? "").toUpperCase();
16
+ if (!ACTIONS.has(action))
17
+ throw new Error(`${label}.action is unsupported.`);
18
+ return Object.freeze({
19
+ action: action,
20
+ recipe: validatePrivacyRecipe(action, declaration.recipe, label, depth + 1),
21
+ ...(allowRequired ? { required: declaration.required !== false } : {}),
22
+ });
23
+ };
24
+ export const validatePrivacyRecipe = (action, recipe, label, depth = 0) => {
25
+ if (depth > MAXIMUM_POLICY_DEPTH) {
26
+ throw new Error(`${label}.recipe exceeds the supported nesting depth.`);
27
+ }
28
+ if (action === "KEEP" || action === "EXCLUDE") {
29
+ if (recipe !== undefined && recipe !== null) {
30
+ throw new Error(`${label}.recipe is not allowed for ${action}.`);
31
+ }
32
+ return null;
33
+ }
34
+ if (!isObject(recipe)) {
35
+ throw new Error(`${label}.recipe is required for ${action}.`);
36
+ }
37
+ if (action === "PSEUDONYMIZE") {
38
+ knownKeys(recipe, [
39
+ "format",
40
+ "namespace",
41
+ "maxLength",
42
+ "length",
43
+ "allowedLengths",
44
+ "origin",
45
+ ], `${label}.recipe`);
46
+ if (typeof recipe.format !== "string" ||
47
+ !PSEUDONYM_FORMATS.has(recipe.format)) {
48
+ throw new Error(`${label}.recipe.format is unsupported.`);
49
+ }
50
+ const normalized = {
51
+ format: recipe.format,
52
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
53
+ };
54
+ if (recipe.format === "text") {
55
+ normalized.maxLength = positiveInteger(recipe.maxLength, `${label}.recipe.maxLength`, 1_024);
56
+ }
57
+ else if (recipe.format === "url") {
58
+ normalized.origin = httpsLoopbackOrigin(recipe.origin, `${label}.recipe.origin`);
59
+ normalized.maxLength = positiveInteger(recipe.maxLength, `${label}.recipe.maxLength`, MAXIMUM_URL_LENGTH);
60
+ const outputLength = normalized.origin.length + URL_PATH_PREFIX.length + URL_TOKEN_LENGTH;
61
+ if (normalized.maxLength < outputLength) {
62
+ throw new Error(`${label}.recipe.maxLength must allow the complete pseudonymous URL (${outputLength} characters).`);
63
+ }
64
+ }
65
+ else if (recipe.maxLength !== undefined) {
66
+ throw new Error(`${label}.recipe.maxLength is supported only for text and url pseudonyms.`);
67
+ }
68
+ if (recipe.format === "hex") {
69
+ if (typeof recipe.length !== "number" ||
70
+ !HEX_LENGTHS.has(recipe.length)) {
71
+ throw new Error(`${label}.recipe.length must be 32 or 64 for hex.`);
72
+ }
73
+ normalized.length = recipe.length;
74
+ }
75
+ else if (recipe.format === "gtin") {
76
+ const hasLength = Object.hasOwn(recipe, "length");
77
+ const hasAllowedLengths = Object.hasOwn(recipe, "allowedLengths");
78
+ if (hasLength === hasAllowedLengths) {
79
+ throw new Error(`${label}.recipe must declare exactly one of length or allowedLengths for gtin.`);
80
+ }
81
+ if (hasLength) {
82
+ if (typeof recipe.length !== "number" ||
83
+ !GTIN_LENGTHS.has(recipe.length)) {
84
+ throw new Error(`${label}.recipe.length must be 8, 12, 13, or 14 for gtin.`);
85
+ }
86
+ normalized.length = recipe.length;
87
+ }
88
+ else {
89
+ if (!Array.isArray(recipe.allowedLengths) ||
90
+ recipe.allowedLengths.length === 0) {
91
+ throw new Error(`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`);
92
+ }
93
+ const allowedLengths = recipe.allowedLengths.map((length) => {
94
+ if (typeof length !== "number" || !GTIN_LENGTHS.has(length)) {
95
+ throw new Error(`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`);
96
+ }
97
+ return length;
98
+ });
99
+ if (new Set(allowedLengths).size !== allowedLengths.length) {
100
+ throw new Error(`${label}.recipe.allowedLengths must not contain duplicates.`);
101
+ }
102
+ normalized.allowedLengths = Object.freeze(allowedLengths);
103
+ }
104
+ }
105
+ else if (recipe.length !== undefined ||
106
+ recipe.allowedLengths !== undefined ||
107
+ (recipe.origin !== undefined && recipe.format !== "url")) {
108
+ throw new Error(`${label}.recipe shape controls do not match its pseudonym format.`);
109
+ }
110
+ return Object.freeze(normalized);
111
+ }
112
+ if (action === "REPLACE") {
113
+ knownKeys(recipe, ["kind", "value"], `${label}.recipe`);
114
+ if (recipe.kind !== "constant" || !Object.hasOwn(recipe, "value")) {
115
+ throw new Error(`${label}.recipe must be an explicit constant.`);
116
+ }
117
+ if (typeof recipe.value === "function" || recipe.value === undefined) {
118
+ throw new Error(`${label}.recipe.value must be JSON-compatible.`);
119
+ }
120
+ JSON.stringify(recipe.value);
121
+ return Object.freeze({ kind: "constant", value: recipe.value });
122
+ }
123
+ knownKeys(recipe, [
124
+ "kind",
125
+ "days",
126
+ "group",
127
+ "representation",
128
+ "format",
129
+ "length",
130
+ "namespace",
131
+ "inputs",
132
+ "mapping",
133
+ "binding",
134
+ "fields",
135
+ "items",
136
+ "variants",
137
+ "keys",
138
+ "values",
139
+ "approved",
140
+ "otherwise",
141
+ "allowNull",
142
+ "maximumBytes",
143
+ "maximumDepth",
144
+ "maximumItems",
145
+ ], `${label}.recipe`);
146
+ if (typeof recipe.kind !== "string" || !DERIVATION_KINDS.has(recipe.kind)) {
147
+ throw new Error(`${label}.recipe.kind is unsupported.`);
148
+ }
149
+ if (recipe.kind === "enum") {
150
+ if (Object.keys(recipe).some((key) => !["kind", "values", "allowNull"].includes(key))) {
151
+ throw new Error(`${label}.recipe contains fields not used by enum.`);
152
+ }
153
+ if (!Array.isArray(recipe.values) ||
154
+ recipe.values.length === 0 ||
155
+ recipe.values.length > MAXIMUM_ENUM_VALUES) {
156
+ throw new Error(`${label}.recipe.values must contain 1 through ${MAXIMUM_ENUM_VALUES} reviewed scalars.`);
157
+ }
158
+ if (recipe.allowNull !== undefined &&
159
+ typeof recipe.allowNull !== "boolean") {
160
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
161
+ }
162
+ const values = Array.from(recipe.values, (value, index) => reviewedScalar(value, `${label}.recipe.values[${index}]`));
163
+ const identities = values.map((value) => JSON.stringify([typeof value, value]));
164
+ if (new Set(identities).size !== identities.length) {
165
+ throw new Error(`${label}.recipe.values must not contain duplicates.`);
166
+ }
167
+ return Object.freeze({
168
+ kind: "enum",
169
+ allowNull: recipe.allowNull === true,
170
+ values: Object.freeze(values),
171
+ });
172
+ }
173
+ if (recipe.kind === "validated-string") {
174
+ if (Object.keys(recipe).some((key) => !["kind", "format", "maximumBytes", "allowNull"].includes(key))) {
175
+ throw new Error(`${label}.recipe contains fields not used by validated-string.`);
176
+ }
177
+ if (recipe.format !== "portable-code") {
178
+ throw new Error(`${label}.recipe.format must be portable-code for validated-string.`);
179
+ }
180
+ const maximumBytes = positiveInteger(recipe.maximumBytes, `${label}.recipe.maximumBytes`, 1_024);
181
+ if (recipe.allowNull !== undefined &&
182
+ typeof recipe.allowNull !== "boolean") {
183
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
184
+ }
185
+ return Object.freeze({
186
+ kind: "validated-string",
187
+ format: "portable-code",
188
+ maximumBytes,
189
+ allowNull: recipe.allowNull === true,
190
+ });
191
+ }
192
+ if (recipe.kind === "digest") {
193
+ if (Object.keys(recipe).some((key) => !["kind", "format", "length", "namespace", "inputs"].includes(key))) {
194
+ throw new Error(`${label}.recipe contains fields not used by digest.`);
195
+ }
196
+ if (recipe.format !== "hex") {
197
+ throw new Error(`${label}.recipe.format must be hex for digest.`);
198
+ }
199
+ if (typeof recipe.length !== "number" || !HEX_LENGTHS.has(recipe.length)) {
200
+ throw new Error(`${label}.recipe.length must be 32 or 64 for digest.`);
201
+ }
202
+ if (!Array.isArray(recipe.inputs) ||
203
+ recipe.inputs.length === 0 ||
204
+ recipe.inputs.length > 32) {
205
+ throw new Error(`${label}.recipe.inputs must contain 1 through 32 columns.`);
206
+ }
207
+ const inputs = recipe.inputs.map((input, index) => identifier(input, `${label}.recipe.inputs[${index}]`));
208
+ if (new Set(inputs).size !== inputs.length) {
209
+ throw new Error(`${label}.recipe.inputs must not contain duplicates.`);
210
+ }
211
+ return Object.freeze({
212
+ kind: "digest",
213
+ format: "hex",
214
+ length: recipe.length,
215
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
216
+ inputs: Object.freeze(inputs),
217
+ });
218
+ }
219
+ if (recipe.kind === "date-shift") {
220
+ const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
221
+ if (Object.keys(recipe).some((key) => !["kind", "days", "group", "representation"].includes(key))) {
222
+ throw new Error(`${label}.recipe contains fields not used by date-shift.`);
223
+ }
224
+ if (recipe.group !== undefined &&
225
+ (typeof recipe.group !== "string" || !BINDING_NAME.test(recipe.group))) {
226
+ throw new Error(`${label}.recipe.group is invalid.`);
227
+ }
228
+ const representation = recipe.representation === undefined
229
+ ? "iso-string"
230
+ : recipe.representation;
231
+ if (typeof representation !== "string" ||
232
+ !DATE_REPRESENTATIONS.has(representation)) {
233
+ throw new Error(`${label}.recipe.representation must be iso-string or epoch-milliseconds.`);
234
+ }
235
+ return Object.freeze({
236
+ kind: "date-shift",
237
+ days,
238
+ representation: representation,
239
+ ...(recipe.group === undefined ? {} : { group: recipe.group }),
240
+ });
241
+ }
242
+ if (recipe.kind === "path-map") {
243
+ if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
244
+ throw new Error(`${label}.recipe contains fields not used by path-map.`);
245
+ }
246
+ if (typeof recipe.mapping !== "string" ||
247
+ !BINDING_NAME.test(recipe.mapping)) {
248
+ throw new Error(`${label}.recipe.mapping is invalid.`);
249
+ }
250
+ return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
251
+ }
252
+ if (recipe.kind === "binding-substitute") {
253
+ if (Object.keys(recipe).some((key) => !["kind", "binding", "format", "namespace", "maximumBytes"].includes(key))) {
254
+ throw new Error(`${label}.recipe contains fields not used by binding-substitute.`);
255
+ }
256
+ if (typeof recipe.binding !== "string" ||
257
+ !BINDING_NAME.test(recipe.binding)) {
258
+ throw new Error(`${label}.recipe.binding is invalid.`);
259
+ }
260
+ if (recipe.format !== "uuid") {
261
+ throw new Error(`${label}.recipe.format must be uuid.`);
262
+ }
263
+ return Object.freeze({
264
+ kind: "binding-substitute",
265
+ binding: recipe.binding,
266
+ format: "uuid",
267
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
268
+ maximumBytes: positiveInteger(recipe.maximumBytes, `${label}.recipe.maximumBytes`, MAXIMUM_TEXT_SUBSTITUTION_BYTES),
269
+ });
270
+ }
271
+ if (recipe.kind === "approved-owner") {
272
+ if (Object.keys(recipe).some((key) => !["kind", "approved", "otherwise"].includes(key))) {
273
+ throw new Error(`${label}.recipe contains fields not used by approved-owner.`);
274
+ }
275
+ return Object.freeze({
276
+ kind: "approved-owner",
277
+ approved: validateNestedDeclaration(recipe.approved, `${label}.recipe.approved`, { depth }),
278
+ otherwise: validateNestedDeclaration(recipe.otherwise, `${label}.recipe.otherwise`, { depth }),
279
+ });
280
+ }
281
+ return validateStructuredJsonRecipe({
282
+ recipe,
283
+ label,
284
+ depth,
285
+ maximumPolicyDepth: MAXIMUM_POLICY_DEPTH,
286
+ validateDeclaration: validateNestedDeclaration,
287
+ validatePseudonymRecipe: (nestedRecipe, nestedLabel, nestedDepth) => validatePrivacyRecipe("PSEUDONYMIZE", nestedRecipe, nestedLabel, nestedDepth),
288
+ });
289
+ };