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

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 (169) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +4 -1
  3. package/dist/package.json +100 -0
  4. package/dist/scripts/install/create_root_config.d.mts +3 -0
  5. package/dist/scripts/install/create_root_config.mjs +13 -0
  6. package/dist/scripts/runtime/manage_database.d.mts +6 -0
  7. package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
  8. package/dist/src/application/session.d.mts +96 -0
  9. package/dist/src/application/session.mjs +380 -0
  10. package/dist/src/baseline/artifact.d.mts +119 -0
  11. package/dist/src/baseline/artifact.mjs +522 -0
  12. package/dist/src/baseline/builder.d.mts +19 -0
  13. package/dist/src/baseline/builder.mjs +142 -0
  14. package/dist/src/baseline/input_discovery.d.mts +13 -0
  15. package/dist/src/baseline/input_discovery.mjs +149 -0
  16. package/dist/src/baseline/policy_review.d.mts +56 -0
  17. package/dist/src/baseline/policy_review.mjs +73 -0
  18. package/dist/src/baseline/preparation.d.mts +63 -0
  19. package/dist/src/baseline/preparation.mjs +295 -0
  20. package/dist/src/baseline/privacy_engine.d.mts +162 -0
  21. package/dist/src/baseline/privacy_engine.mjs +1162 -0
  22. package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
  23. package/dist/src/baseline/privacy_structured_json.mjs +464 -0
  24. package/dist/src/baseline/sanitization_policy.d.mts +69 -0
  25. package/dist/src/baseline/sanitization_policy.mjs +227 -0
  26. package/dist/src/baseline/schema_snapshot.d.mts +19 -0
  27. package/dist/src/baseline/schema_snapshot.mjs +79 -0
  28. package/dist/src/cli/arguments.d.mts +43 -0
  29. package/dist/src/cli/arguments.mjs +135 -0
  30. package/dist/src/cli/guided.d.mts +51 -0
  31. package/dist/src/cli/guided.mjs +713 -0
  32. package/dist/src/cli/init_command.d.mts +21 -0
  33. package/dist/src/cli/init_command.mjs +90 -0
  34. package/dist/src/cli/project_commands.d.mts +157 -0
  35. package/dist/src/cli/project_commands.mjs +183 -0
  36. package/dist/src/cli/rehearsal.d.mts +7 -0
  37. package/dist/src/cli/rehearsal.mjs +706 -0
  38. package/dist/src/cli/renderers.d.mts +299 -0
  39. package/dist/src/cli/renderers.mjs +554 -0
  40. package/dist/src/cli/runtime_commands.d.mts +265 -0
  41. package/dist/src/cli/runtime_commands.mjs +599 -0
  42. package/dist/src/cli/source_commands.d.mts +432 -0
  43. package/dist/src/cli/source_commands.mjs +299 -0
  44. package/dist/src/cli/terminal.d.mts +59 -0
  45. package/dist/src/cli/terminal.mjs +230 -0
  46. package/dist/src/identity/claim.d.mts +144 -0
  47. package/dist/src/identity/claim.mjs +947 -0
  48. package/dist/src/identity/provider_configuration.d.mts +37 -0
  49. package/dist/src/identity/provider_configuration.mjs +132 -0
  50. package/dist/src/identity/storage.d.mts +21 -0
  51. package/dist/src/identity/storage.mjs +122 -0
  52. package/dist/src/index.d.mts +4 -0
  53. package/dist/src/index.mjs +3 -0
  54. package/dist/src/project/configuration.d.mts +181 -0
  55. package/dist/src/project/configuration.mjs +958 -0
  56. package/dist/src/project/install_scaffold.d.mts +42 -0
  57. package/dist/src/project/install_scaffold.mjs +263 -0
  58. package/dist/src/project/setup.d.mts +87 -0
  59. package/dist/src/project/setup.mjs +414 -0
  60. package/dist/src/project/support_report.d.mts +88 -0
  61. package/dist/src/project/support_report.mjs +83 -0
  62. package/dist/src/runtime/cleanup.d.mts +85 -0
  63. package/dist/src/runtime/cleanup.mjs +311 -0
  64. package/dist/src/runtime/migration_history.d.mts +51 -0
  65. package/dist/src/runtime/migration_history.mjs +170 -0
  66. package/dist/src/runtime/plan.d.mts +134 -0
  67. package/dist/src/runtime/plan.mjs +636 -0
  68. package/dist/src/runtime/policy.d.mts +66 -0
  69. package/dist/src/runtime/policy.mjs +369 -0
  70. package/dist/src/runtime/restore.d.mts +22 -0
  71. package/dist/src/runtime/restore.mjs +276 -0
  72. package/dist/src/runtime/service_environment.d.mts +11 -0
  73. package/dist/src/runtime/service_environment.mjs +58 -0
  74. package/dist/src/runtime/topology.d.mts +51 -0
  75. package/dist/src/runtime/topology.mjs +128 -0
  76. package/dist/src/shared/diagnostics.d.mts +97 -0
  77. package/dist/src/shared/diagnostics.mjs +170 -0
  78. package/dist/src/shared/human_output.d.mts +2 -0
  79. package/{src → dist/src}/shared/human_output.mjs +1 -3
  80. package/dist/src/shared/node_runtime.d.mts +14 -0
  81. package/dist/src/shared/node_runtime.mjs +25 -0
  82. package/dist/src/shared/operation_guard.d.mts +22 -0
  83. package/dist/src/shared/operation_guard.mjs +150 -0
  84. package/dist/src/shared/process_environment.d.mts +15 -0
  85. package/dist/src/shared/process_environment.mjs +50 -0
  86. package/dist/src/source/access.d.mts +165 -0
  87. package/dist/src/source/access.mjs +602 -0
  88. package/dist/src/source/asset_transfer.d.mts +27 -0
  89. package/dist/src/source/asset_transfer.mjs +202 -0
  90. package/dist/src/source/baseline.d.mts +66 -0
  91. package/dist/src/source/baseline.mjs +372 -0
  92. package/dist/src/source/external_reader.d.mts +42 -0
  93. package/dist/src/source/external_reader.mjs +278 -0
  94. package/dist/src/source/postgresql_access.d.mts +86 -0
  95. package/dist/src/source/postgresql_access.mjs +454 -0
  96. package/dist/src/source/privacy_paths.d.mts +10 -0
  97. package/dist/src/source/privacy_paths.mjs +27 -0
  98. package/dist/src/targets/driver.d.mts +2 -0
  99. package/dist/src/targets/driver.mjs +13 -0
  100. package/dist/src/targets/postgresql.d.mts +5 -0
  101. package/dist/src/targets/postgresql.mjs +641 -0
  102. package/dist/src/targets/supabase.d.mts +8 -0
  103. package/dist/src/targets/supabase.mjs +659 -0
  104. package/dist/src/targets/supabase_environment.d.mts +49 -0
  105. package/dist/src/targets/supabase_environment.mjs +162 -0
  106. package/dist/src/targets/target.d.mts +33 -0
  107. package/dist/src/targets/target.mjs +39 -0
  108. package/docs/README.md +1 -0
  109. package/docs/architecture.md +17 -9
  110. package/docs/configuration.md +49 -8
  111. package/docs/getting-started.md +28 -0
  112. package/docs/index.md +1 -0
  113. package/docs/production-source.md +43 -7
  114. package/docs/releasing.md +5 -3
  115. package/docs/roadmap.md +3 -2
  116. package/docs/sanitization.md +415 -14
  117. package/docs/standalone-workflow.md +12 -6
  118. package/docs/troubleshooting.md +5 -1
  119. package/docs/tutorial.md +5 -0
  120. package/docs/typescript.md +87 -0
  121. package/package.json +35 -29
  122. package/scripts/install/create_root_config.mjs +0 -36
  123. package/src/README.md +0 -17
  124. package/src/application/session.mjs +0 -435
  125. package/src/baseline/artifact.mjs +0 -684
  126. package/src/baseline/builder.mjs +0 -164
  127. package/src/baseline/input_discovery.mjs +0 -155
  128. package/src/baseline/policy_review.mjs +0 -92
  129. package/src/baseline/preparation.mjs +0 -323
  130. package/src/baseline/privacy_engine.mjs +0 -952
  131. package/src/baseline/sanitization_policy.mjs +0 -299
  132. package/src/baseline/schema_snapshot.mjs +0 -113
  133. package/src/cli/arguments.mjs +0 -126
  134. package/src/cli/guided.mjs +0 -814
  135. package/src/cli/init_command.mjs +0 -94
  136. package/src/cli/rehearsal.mjs +0 -970
  137. package/src/cli/renderers.mjs +0 -626
  138. package/src/cli/runtime_commands.mjs +0 -707
  139. package/src/cli/source_commands.mjs +0 -347
  140. package/src/cli/terminal.mjs +0 -275
  141. package/src/identity/claim.mjs +0 -1163
  142. package/src/identity/provider_configuration.mjs +0 -177
  143. package/src/identity/storage.mjs +0 -165
  144. package/src/project/configuration.mjs +0 -1337
  145. package/src/project/install_scaffold.mjs +0 -291
  146. package/src/project/setup.mjs +0 -466
  147. package/src/project/support_report.mjs +0 -105
  148. package/src/runtime/cleanup.mjs +0 -381
  149. package/src/runtime/migration_history.mjs +0 -220
  150. package/src/runtime/plan.mjs +0 -722
  151. package/src/runtime/policy.mjs +0 -438
  152. package/src/runtime/restore.mjs +0 -337
  153. package/src/runtime/service_environment.mjs +0 -82
  154. package/src/runtime/topology.mjs +0 -177
  155. package/src/shared/diagnostics.mjs +0 -207
  156. package/src/shared/node_runtime.mjs +0 -31
  157. package/src/shared/operation_guard.mjs +0 -162
  158. package/src/shared/process_environment.mjs +0 -68
  159. package/src/source/access.mjs +0 -753
  160. package/src/source/asset_transfer.mjs +0 -207
  161. package/src/source/baseline.mjs +0 -482
  162. package/src/source/external_reader.mjs +0 -255
  163. package/src/source/postgresql_access.mjs +0 -581
  164. package/src/source/privacy_paths.mjs +0 -38
  165. package/src/targets/postgresql.mjs +0 -808
  166. package/src/targets/supabase.mjs +0 -840
  167. package/src/targets/supabase_environment.mjs +0 -229
  168. package/src/targets/target.mjs +0 -66
  169. /package/{src/project → types}/configuration.d.mts +0 -0
@@ -1,952 +0,0 @@
1
- /**
2
- * Execute reviewed privacy policy version 2 without project-owned callbacks.
3
- * The engine is intentionally small and fail-closed: unsupported shapes and
4
- * recipes must be reviewed and added here instead of evaluated as code.
5
- */
6
-
7
- import { createHash, createHmac, randomBytes } from "node:crypto";
8
- import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
9
- import { dirname } from "node:path";
10
-
11
- const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
12
- const BINDING_NAME = /^[a-z][a-z0-9-]{0,62}$/u;
13
- const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
14
- const HEX_64 = /^[a-f0-9]{64}$/u;
15
- const ACTIONS = new Set([
16
- "KEEP",
17
- "PSEUDONYMIZE",
18
- "REPLACE",
19
- "EXCLUDE",
20
- "DERIVE",
21
- ]);
22
- const PSEUDONYM_FORMATS = new Set(["uuid", "email", "text", "integer"]);
23
- const DERIVATION_KINDS = new Set([
24
- "date-shift",
25
- "path-map",
26
- "json-object",
27
- "json-array",
28
- "approved-owner",
29
- ]);
30
- const KEY_BYTES = 32;
31
- const DEFAULT_JSON_MAXIMUM_BYTES = 65_536;
32
- const DEFAULT_JSON_MAXIMUM_DEPTH = 8;
33
- const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
34
- const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
35
- const MAXIMUM_POLICY_DEPTH = 16;
36
-
37
- const isObject = (value) =>
38
- value !== null &&
39
- typeof value === "object" &&
40
- !Array.isArray(value) &&
41
- Object.getPrototypeOf(value) === Object.prototype;
42
-
43
- const knownKeys = (value, allowed, label) => {
44
- for (const key of Object.keys(value)) {
45
- if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
46
- }
47
- };
48
-
49
- const identifier = (value, label) => {
50
- if (!IDENTIFIER.test(value ?? "")) {
51
- throw new Error(`${label} must be a lowercase PostgreSQL identifier.`);
52
- }
53
- return value;
54
- };
55
-
56
- const nonEmpty = (value, label) => {
57
- if (typeof value !== "string" || !value.trim()) {
58
- throw new Error(`${label} must be a non-empty string.`);
59
- }
60
- return value.trim();
61
- };
62
-
63
- const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
64
- if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
65
- throw new Error(`${label} must be an integer from 1 through ${maximum}.`);
66
- }
67
- return value;
68
- };
69
-
70
- const validateNestedDeclaration = (
71
- declaration,
72
- label,
73
- { allowRequired = false, depth = 0 } = {},
74
- ) => {
75
- if (!isObject(declaration)) throw new Error(`${label} must be an object.`);
76
- knownKeys(
77
- declaration,
78
- allowRequired ? ["action", "recipe", "required"] : ["action", "recipe"],
79
- label,
80
- );
81
- if (
82
- allowRequired &&
83
- declaration.required !== undefined &&
84
- typeof declaration.required !== "boolean"
85
- ) {
86
- throw new Error(`${label}.required must be true or false.`);
87
- }
88
- const action = String(declaration.action ?? "").toUpperCase();
89
- if (!ACTIONS.has(action)) throw new Error(`${label}.action is unsupported.`);
90
- return Object.freeze({
91
- action,
92
- recipe: validateRecipe(action, declaration.recipe, label, depth + 1),
93
- ...(allowRequired ? { required: declaration.required !== false } : {}),
94
- });
95
- };
96
-
97
- const validateRecipe = (action, recipe, label, depth = 0) => {
98
- if (depth > MAXIMUM_POLICY_DEPTH) {
99
- throw new Error(`${label}.recipe exceeds the supported nesting depth.`);
100
- }
101
- if (action === "KEEP" || action === "EXCLUDE") {
102
- if (recipe !== undefined && recipe !== null) {
103
- throw new Error(`${label}.recipe is not allowed for ${action}.`);
104
- }
105
- return null;
106
- }
107
- if (!isObject(recipe)) {
108
- throw new Error(`${label}.recipe is required for ${action}.`);
109
- }
110
- if (action === "PSEUDONYMIZE") {
111
- knownKeys(recipe, ["format", "namespace", "maxLength"], `${label}.recipe`);
112
- if (!PSEUDONYM_FORMATS.has(recipe.format)) {
113
- throw new Error(`${label}.recipe.format is unsupported.`);
114
- }
115
- const normalized = {
116
- format: recipe.format,
117
- namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
118
- };
119
- if (recipe.format === "text") {
120
- normalized.maxLength = positiveInteger(
121
- recipe.maxLength,
122
- `${label}.recipe.maxLength`,
123
- 1_024,
124
- );
125
- } else if (recipe.maxLength !== undefined) {
126
- throw new Error(
127
- `${label}.recipe.maxLength is supported only for text pseudonyms.`,
128
- );
129
- }
130
- return Object.freeze(normalized);
131
- }
132
- if (action === "REPLACE") {
133
- knownKeys(recipe, ["kind", "value"], `${label}.recipe`);
134
- if (recipe.kind !== "constant" || !Object.hasOwn(recipe, "value")) {
135
- throw new Error(`${label}.recipe must be an explicit constant.`);
136
- }
137
- if (typeof recipe.value === "function" || recipe.value === undefined) {
138
- throw new Error(`${label}.recipe.value must be JSON-compatible.`);
139
- }
140
- JSON.stringify(recipe.value);
141
- return Object.freeze({ kind: "constant", value: recipe.value });
142
- }
143
- knownKeys(
144
- recipe,
145
- [
146
- "kind",
147
- "days",
148
- "group",
149
- "mapping",
150
- "fields",
151
- "items",
152
- "approved",
153
- "otherwise",
154
- "allowNull",
155
- "maximumBytes",
156
- "maximumDepth",
157
- "maximumItems",
158
- ],
159
- `${label}.recipe`,
160
- );
161
- if (!DERIVATION_KINDS.has(recipe.kind)) {
162
- throw new Error(`${label}.recipe.kind is unsupported.`);
163
- }
164
- if (recipe.kind === "date-shift") {
165
- const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
166
- if (
167
- Object.keys(recipe).some(
168
- (key) => !["kind", "days", "group"].includes(key),
169
- )
170
- ) {
171
- throw new Error(
172
- `${label}.recipe contains fields not used by date-shift.`,
173
- );
174
- }
175
- if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
176
- throw new Error(`${label}.recipe.group is invalid.`);
177
- }
178
- return Object.freeze({
179
- kind: "date-shift",
180
- days,
181
- ...(recipe.group === undefined ? {} : { group: recipe.group }),
182
- });
183
- }
184
- if (recipe.kind === "path-map") {
185
- if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
186
- throw new Error(`${label}.recipe contains fields not used by path-map.`);
187
- }
188
- if (!BINDING_NAME.test(recipe.mapping ?? "")) {
189
- throw new Error(`${label}.recipe.mapping is invalid.`);
190
- }
191
- return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
192
- }
193
- if (recipe.kind === "approved-owner") {
194
- if (
195
- Object.keys(recipe).some(
196
- (key) => !["kind", "approved", "otherwise"].includes(key),
197
- )
198
- ) {
199
- throw new Error(
200
- `${label}.recipe contains fields not used by approved-owner.`,
201
- );
202
- }
203
- return Object.freeze({
204
- kind: "approved-owner",
205
- approved: validateNestedDeclaration(
206
- recipe.approved,
207
- `${label}.recipe.approved`,
208
- { depth },
209
- ),
210
- otherwise: validateNestedDeclaration(
211
- recipe.otherwise,
212
- `${label}.recipe.otherwise`,
213
- { depth },
214
- ),
215
- });
216
- }
217
- if (recipe.allowNull !== undefined && typeof recipe.allowNull !== "boolean") {
218
- throw new Error(`${label}.recipe.allowNull must be true or false.`);
219
- }
220
- const bounds = Object.freeze({
221
- maximumBytes: positiveInteger(
222
- recipe.maximumBytes ?? DEFAULT_JSON_MAXIMUM_BYTES,
223
- `${label}.recipe.maximumBytes`,
224
- 16_777_216,
225
- ),
226
- maximumDepth: positiveInteger(
227
- recipe.maximumDepth ?? DEFAULT_JSON_MAXIMUM_DEPTH,
228
- `${label}.recipe.maximumDepth`,
229
- MAXIMUM_POLICY_DEPTH,
230
- ),
231
- });
232
- if (recipe.kind === "json-array") {
233
- if (
234
- Object.keys(recipe).some(
235
- (key) =>
236
- ![
237
- "kind",
238
- "items",
239
- "allowNull",
240
- "maximumBytes",
241
- "maximumDepth",
242
- "maximumItems",
243
- ].includes(key),
244
- )
245
- ) {
246
- throw new Error(
247
- `${label}.recipe contains fields not used by json-array.`,
248
- );
249
- }
250
- return Object.freeze({
251
- kind: "json-array",
252
- allowNull: recipe.allowNull === true,
253
- ...bounds,
254
- maximumItems: positiveInteger(
255
- recipe.maximumItems,
256
- `${label}.recipe.maximumItems`,
257
- 10_000,
258
- ),
259
- items: validateNestedDeclaration(recipe.items, `${label}.recipe.items`, {
260
- depth,
261
- }),
262
- });
263
- }
264
- if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
265
- throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
266
- }
267
- if (
268
- Object.keys(recipe).some(
269
- (key) =>
270
- ![
271
- "kind",
272
- "fields",
273
- "allowNull",
274
- "maximumBytes",
275
- "maximumDepth",
276
- ].includes(key),
277
- )
278
- ) {
279
- throw new Error(`${label}.recipe contains fields not used by json-object.`);
280
- }
281
- const fields = Object.fromEntries(
282
- Object.entries(recipe.fields).map(([key, declaration]) => {
283
- if (!key) {
284
- throw new Error(`${label}.recipe.fields contains an invalid key.`);
285
- }
286
- return [
287
- key,
288
- validateNestedDeclaration(
289
- declaration,
290
- `${label}.recipe.fields.${key}`,
291
- { allowRequired: true, depth },
292
- ),
293
- ];
294
- }),
295
- );
296
- return Object.freeze({
297
- kind: "json-object",
298
- allowNull: recipe.allowNull === true,
299
- ...bounds,
300
- fields: Object.freeze(fields),
301
- });
302
- };
303
-
304
- export const validateExecutablePrivacyPolicy = (policy) => {
305
- if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
306
- knownKeys(
307
- policy,
308
- [
309
- "policyVersion",
310
- "draft",
311
- "migrationCutoff",
312
- "bindings",
313
- "pathMappings",
314
- "tables",
315
- ],
316
- "policy",
317
- );
318
- if (policy.policyVersion !== 2) {
319
- throw new Error("Executable privacy policies must use policyVersion 2.");
320
- }
321
- if (policy.draft === true) {
322
- throw new Error("The privacy policy is still a REVIEW REQUIRED draft.");
323
- }
324
- if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
325
- throw new Error(
326
- "Privacy policy migrationCutoff must be a timestamp version.",
327
- );
328
- }
329
- if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
330
- throw new Error("Privacy policy must classify at least one table.");
331
- }
332
- if (policy.bindings !== undefined && !isObject(policy.bindings)) {
333
- throw new Error("policy.bindings must be an object.");
334
- }
335
- const bindings = Object.fromEntries(
336
- Object.entries(policy.bindings ?? {}).map(([name, binding]) => {
337
- if (!BINDING_NAME.test(name) || !isObject(binding)) {
338
- throw new Error("policy.bindings contains an invalid binding.");
339
- }
340
- knownKeys(
341
- binding,
342
- ["environmentVariable", "approvedValueSha256"],
343
- `policy.bindings.${name}`,
344
- );
345
- if (!ENVIRONMENT_KEY.test(binding.environmentVariable ?? "")) {
346
- throw new Error(
347
- `policy.bindings.${name}.environmentVariable is invalid.`,
348
- );
349
- }
350
- if (!HEX_64.test(binding.approvedValueSha256 ?? "")) {
351
- throw new Error(
352
- `policy.bindings.${name}.approvedValueSha256 must be a lowercase SHA-256 receipt.`,
353
- );
354
- }
355
- return [name, Object.freeze({ ...binding })];
356
- }),
357
- );
358
- if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
359
- throw new Error("policy.pathMappings must be an object.");
360
- }
361
- const pathMappings = Object.fromEntries(
362
- Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
363
- const label = `policy.pathMappings.${name}`;
364
- if (!BINDING_NAME.test(name) || !isObject(mapping)) {
365
- throw new Error("policy.pathMappings contains an invalid mapping.");
366
- }
367
- knownKeys(
368
- mapping,
369
- [
370
- "binding",
371
- "format",
372
- "namespace",
373
- "maxLength",
374
- "maximumBytes",
375
- "maximumSegments",
376
- ],
377
- label,
378
- );
379
- if (!Object.hasOwn(bindings, mapping.binding ?? "")) {
380
- throw new Error(`${label}.binding references an unknown binding.`);
381
- }
382
- if (!["uuid", "text"].includes(mapping.format)) {
383
- throw new Error(`${label}.format must be uuid or text.`);
384
- }
385
- const normalized = {
386
- binding: mapping.binding,
387
- format: mapping.format,
388
- namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
389
- maximumBytes: positiveInteger(
390
- mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES,
391
- `${label}.maximumBytes`,
392
- 16_384,
393
- ),
394
- maximumSegments: positiveInteger(
395
- mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS,
396
- `${label}.maximumSegments`,
397
- 256,
398
- ),
399
- };
400
- if (mapping.format === "text") {
401
- normalized.maxLength = positiveInteger(
402
- mapping.maxLength,
403
- `${label}.maxLength`,
404
- 1_024,
405
- );
406
- } else if (mapping.maxLength !== undefined) {
407
- throw new Error(`${label}.maxLength is supported only for text.`);
408
- }
409
- return [name, Object.freeze(normalized)];
410
- }),
411
- );
412
- const names = new Set();
413
- const tables = policy.tables.map((table, tableIndex) => {
414
- const label = `policy.tables[${tableIndex}]`;
415
- if (!isObject(table)) throw new Error(`${label} must be an object.`);
416
- knownKeys(
417
- table,
418
- ["schema", "name", "sourceRows", "ownerBinding", "columns"],
419
- label,
420
- );
421
- const schema = identifier(table.schema ?? "public", `${label}.schema`);
422
- const name = identifier(table.name, `${label}.name`);
423
- const key = schema === "public" ? name : `${schema}.${name}`;
424
- if (names.has(key))
425
- throw new Error(`Privacy policy duplicates table ${key}.`);
426
- names.add(key);
427
- if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
428
- throw new Error(`${label}.sourceRows is unsupported.`);
429
- }
430
- if (!Array.isArray(table.columns) || table.columns.length === 0) {
431
- throw new Error(`${label}.columns must not be empty.`);
432
- }
433
- const columnNames = new Set();
434
- const columns = table.columns.map((column, columnIndex) => {
435
- const columnLabel = `${label}.columns[${columnIndex}]`;
436
- if (!isObject(column))
437
- throw new Error(`${columnLabel} must be an object.`);
438
- knownKeys(
439
- column,
440
- ["name", "action", "recipe", "generated", "identity", "foreignKey"],
441
- columnLabel,
442
- );
443
- const columnName = identifier(column.name, `${columnLabel}.name`);
444
- if (columnNames.has(columnName)) {
445
- throw new Error(
446
- `Privacy policy duplicates column ${name}.${columnName}.`,
447
- );
448
- }
449
- columnNames.add(columnName);
450
- const action = String(column.action ?? "").toUpperCase();
451
- if (!ACTIONS.has(action))
452
- throw new Error(`${columnLabel}.action is unsupported.`);
453
- if (!["ALWAYS", "NEVER"].includes(column.generated)) {
454
- throw new Error(`${columnLabel}.generated must be ALWAYS or NEVER.`);
455
- }
456
- if (!["YES", "NO"].includes(column.identity)) {
457
- throw new Error(`${columnLabel}.identity must be YES or NO.`);
458
- }
459
- if (column.foreignKey !== null) {
460
- if (!isObject(column.foreignKey)) {
461
- throw new Error(
462
- `${columnLabel}.foreignKey must be null or an object.`,
463
- );
464
- }
465
- knownKeys(
466
- column.foreignKey,
467
- ["schema", "table", "column"],
468
- `${columnLabel}.foreignKey`,
469
- );
470
- identifier(
471
- column.foreignKey.schema,
472
- `${columnLabel}.foreignKey.schema`,
473
- );
474
- identifier(column.foreignKey.table, `${columnLabel}.foreignKey.table`);
475
- identifier(
476
- column.foreignKey.column,
477
- `${columnLabel}.foreignKey.column`,
478
- );
479
- }
480
- return Object.freeze({
481
- ...column,
482
- name: columnName,
483
- action,
484
- recipe: validateRecipe(action, column.recipe, columnLabel),
485
- });
486
- });
487
- let ownerBinding = null;
488
- if (table.ownerBinding !== undefined && table.ownerBinding !== null) {
489
- if (!isObject(table.ownerBinding)) {
490
- throw new Error(`${label}.ownerBinding must be an object.`);
491
- }
492
- knownKeys(
493
- table.ownerBinding,
494
- ["binding", "column"],
495
- `${label}.ownerBinding`,
496
- );
497
- const binding = nonEmpty(
498
- table.ownerBinding.binding,
499
- `${label}.ownerBinding.binding`,
500
- );
501
- const column = identifier(
502
- table.ownerBinding.column,
503
- `${label}.ownerBinding.column`,
504
- );
505
- if (!Object.hasOwn(bindings, binding)) {
506
- throw new Error(`${label}.ownerBinding references an unknown binding.`);
507
- }
508
- if (!columnNames.has(column)) {
509
- throw new Error(`${label}.ownerBinding references an unknown column.`);
510
- }
511
- const ownerColumn = columns.find((entry) => entry.name === column);
512
- if (ownerColumn.action !== "PSEUDONYMIZE") {
513
- throw new Error(
514
- `${label}.ownerBinding column must be explicitly pseudonymized.`,
515
- );
516
- }
517
- ownerBinding = Object.freeze({ binding, column });
518
- }
519
- const usesApprovedOwner = (declaration) =>
520
- declaration.recipe?.kind === "approved-owner" ||
521
- (declaration.recipe?.kind === "json-object" &&
522
- Object.values(declaration.recipe.fields).some(usesApprovedOwner)) ||
523
- (declaration.recipe?.kind === "json-array" &&
524
- usesApprovedOwner(declaration.recipe.items));
525
- if (columns.some(usesApprovedOwner) && !ownerBinding) {
526
- throw new Error(`${label} uses approved-owner without ownerBinding.`);
527
- }
528
- const visitRecipes = (declaration, visit) => {
529
- visit(declaration.recipe);
530
- if (declaration.recipe?.kind === "approved-owner") {
531
- visitRecipes(declaration.recipe.approved, visit);
532
- visitRecipes(declaration.recipe.otherwise, visit);
533
- } else if (declaration.recipe?.kind === "json-object") {
534
- Object.values(declaration.recipe.fields).forEach((nested) =>
535
- visitRecipes(nested, visit),
536
- );
537
- } else if (declaration.recipe?.kind === "json-array") {
538
- visitRecipes(declaration.recipe.items, visit);
539
- }
540
- };
541
- for (const column of columns) {
542
- visitRecipes(column, (recipe) => {
543
- if (
544
- recipe?.kind === "path-map" &&
545
- !Object.hasOwn(pathMappings, recipe.mapping)
546
- ) {
547
- throw new Error(
548
- `${label} references unknown path mapping ${recipe.mapping}.`,
549
- );
550
- }
551
- });
552
- }
553
- return Object.freeze({
554
- schema,
555
- name,
556
- sourceRows: table.sourceRows,
557
- ownerBinding,
558
- columns: Object.freeze(columns),
559
- });
560
- });
561
- const dateShiftGroups = new Map();
562
- const visitDateShiftGroups = (declaration) => {
563
- const recipe = declaration.recipe;
564
- if (recipe?.kind === "date-shift" && recipe.group) {
565
- const existing = dateShiftGroups.get(recipe.group);
566
- if (existing !== undefined && existing !== recipe.days) {
567
- throw new Error(
568
- `Privacy date-shift group ${recipe.group} must use one days value.`,
569
- );
570
- }
571
- dateShiftGroups.set(recipe.group, recipe.days);
572
- }
573
- if (recipe?.kind === "approved-owner") {
574
- visitDateShiftGroups(recipe.approved);
575
- visitDateShiftGroups(recipe.otherwise);
576
- } else if (recipe?.kind === "json-object") {
577
- Object.values(recipe.fields).forEach(visitDateShiftGroups);
578
- } else if (recipe?.kind === "json-array") {
579
- visitDateShiftGroups(recipe.items);
580
- }
581
- };
582
- tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
583
- return Object.freeze({
584
- policyVersion: 2,
585
- migrationCutoff: policy.migrationCutoff,
586
- bindings: Object.freeze(bindings),
587
- pathMappings: Object.freeze(pathMappings),
588
- tables: Object.freeze(tables),
589
- });
590
- };
591
-
592
- const digest = (key, namespace, value) =>
593
- createHmac("sha256", key)
594
- .update(namespace)
595
- .update("\0")
596
- .update(JSON.stringify(value))
597
- .digest();
598
-
599
- const pseudonym = (key, recipe, value) => {
600
- if (value === null) return null;
601
- const bytes = digest(key, recipe.namespace, value);
602
- if (recipe.format === "uuid") {
603
- const copy = Buffer.from(bytes.subarray(0, 16));
604
- copy[6] = (copy[6] & 0x0f) | 0x40;
605
- copy[8] = (copy[8] & 0x3f) | 0x80;
606
- const hex = copy.toString("hex");
607
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
608
- }
609
- if (recipe.format === "email") {
610
- return `rehearsal+${bytes.toString("hex").slice(0, 24)}@example.invalid`;
611
- }
612
- if (recipe.format === "integer") {
613
- return Number(bytes.readBigUInt64BE(0) % 9_007_199_254_740_991n) + 1;
614
- }
615
- const token = `rehearsal_${bytes.toString("hex")}`;
616
- return token.slice(0, recipe.maxLength);
617
- };
618
-
619
- const remapPath = ({ key, mapping, bindingValue, value, label }) => {
620
- if (typeof value !== "string") {
621
- throw new Error(`${label} must be a Storage-style path string.`);
622
- }
623
- if (Buffer.byteLength(value) > mapping.maximumBytes) {
624
- throw new Error(`${label} exceeds its reviewed byte limit.`);
625
- }
626
- const segments = value.split("/");
627
- if (
628
- !value ||
629
- value.startsWith("/") ||
630
- value.endsWith("/") ||
631
- segments.length > mapping.maximumSegments ||
632
- segments.some((segment) => !segment || segment === "." || segment === "..")
633
- ) {
634
- throw new Error(`${label} is not a safe bounded Storage path.`);
635
- }
636
- if (
637
- typeof bindingValue !== "string" ||
638
- !bindingValue ||
639
- bindingValue.includes("/") ||
640
- bindingValue === "." ||
641
- bindingValue === ".."
642
- ) {
643
- throw new Error(`${label} has an invalid reviewed identity binding.`);
644
- }
645
- if (segments[0] !== bindingValue) {
646
- throw new Error(
647
- `${label} does not begin with its exact reviewed identity.`,
648
- );
649
- }
650
- const destination = [
651
- String(pseudonym(key, mapping, bindingValue)),
652
- ...segments.slice(1),
653
- ].join("/");
654
- if (Buffer.byteLength(destination) > mapping.maximumBytes) {
655
- throw new Error(`${label} destination exceeds its reviewed byte limit.`);
656
- }
657
- return destination;
658
- };
659
-
660
- const assertJsonBounds = (value, recipe, path) => {
661
- let bytes = 0;
662
- const jsonStringBytes = (string) => {
663
- let size = 2;
664
- for (let index = 0; index < string.length; index += 1) {
665
- const code = string.charCodeAt(index);
666
- if (code === 34 || code === 92) size += 2;
667
- else if (code < 32) size += [8, 9, 10, 12, 13].includes(code) ? 2 : 6;
668
- else if (code >= 0xd800 && code <= 0xdbff) {
669
- const next = string.charCodeAt(index + 1);
670
- if (next >= 0xdc00 && next <= 0xdfff) {
671
- size += 4;
672
- index += 1;
673
- } else size += 6;
674
- } else if (code >= 0xdc00 && code <= 0xdfff) size += 6;
675
- else if (code <= 0x7f) size += 1;
676
- else if (code <= 0x7ff) size += 2;
677
- else size += 3;
678
- }
679
- return size;
680
- };
681
- const visit = (current, depth) => {
682
- if (depth > recipe.maximumDepth) {
683
- throw new Error(`${path} exceeds maximumDepth ${recipe.maximumDepth}.`);
684
- }
685
- if (typeof current === "string") bytes += jsonStringBytes(current);
686
- else if (current === null) bytes += 4;
687
- else if (typeof current === "number" && !Number.isFinite(current))
688
- throw new Error(`${path} contains an unsupported JSON value.`);
689
- else if (["number", "boolean"].includes(typeof current))
690
- bytes += String(current).length;
691
- else if (Array.isArray(current)) {
692
- if (
693
- recipe.kind === "json-array" &&
694
- current.length > recipe.maximumItems
695
- ) {
696
- throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
697
- }
698
- bytes += 2 + Math.max(0, current.length - 1);
699
- for (const item of current) visit(item, depth + 1);
700
- } else if (isObject(current)) {
701
- bytes += 2 + Math.max(0, Object.keys(current).length - 1);
702
- for (const [key, nested] of Object.entries(current)) {
703
- bytes += jsonStringBytes(key) + 1;
704
- visit(nested, depth + 1);
705
- }
706
- } else throw new Error(`${path} contains an unsupported JSON value.`);
707
- if (bytes > recipe.maximumBytes) {
708
- throw new Error(`${path} exceeds maximumBytes ${recipe.maximumBytes}.`);
709
- }
710
- };
711
- visit(value, 1);
712
- };
713
-
714
- const executeDeclaration = ({
715
- declaration,
716
- value,
717
- key,
718
- path,
719
- ownerApproved,
720
- pathMappings,
721
- bindingValues,
722
- }) => {
723
- if (declaration.action === "KEEP") return value;
724
- if (declaration.action === "EXCLUDE") return undefined;
725
- if (declaration.action === "REPLACE")
726
- return structuredClone(declaration.recipe.value);
727
- if (declaration.action === "PSEUDONYMIZE") {
728
- return pseudonym(key, declaration.recipe, value);
729
- }
730
- if (declaration.recipe.kind === "date-shift") {
731
- if (value === null) return null;
732
- const date = new Date(value);
733
- if (!Number.isFinite(date.valueOf()))
734
- throw new Error(`${path} is not a valid date.`);
735
- const direction = declaration.recipe.group
736
- ? digest(key, "date-shift-group", declaration.recipe.group)[0] % 2 === 0
737
- ? -1
738
- : 1
739
- : digest(key, path, value)[0] % 2 === 0
740
- ? -1
741
- : 1;
742
- date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
743
- return date.toISOString();
744
- }
745
- if (declaration.recipe.kind === "path-map") {
746
- const mapping = pathMappings[declaration.recipe.mapping];
747
- return remapPath({
748
- key,
749
- mapping,
750
- bindingValue: bindingValues.get(mapping.binding),
751
- value,
752
- label: path,
753
- });
754
- }
755
- if (declaration.recipe.kind === "approved-owner") {
756
- if (ownerApproved === undefined) {
757
- throw new Error(`${path} has no reviewed owner condition.`);
758
- }
759
- return executeDeclaration({
760
- declaration: ownerApproved
761
- ? declaration.recipe.approved
762
- : declaration.recipe.otherwise,
763
- value,
764
- key,
765
- path,
766
- ownerApproved,
767
- pathMappings,
768
- bindingValues,
769
- });
770
- }
771
- if (value === null && declaration.recipe.allowNull) return null;
772
- assertJsonBounds(value, declaration.recipe, path);
773
- if (declaration.recipe.kind === "json-array") {
774
- if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
775
- return value.flatMap((item, index) => {
776
- const transformed = executeDeclaration({
777
- declaration: declaration.recipe.items,
778
- value: item,
779
- key,
780
- path: `${path}[${index}]`,
781
- ownerApproved,
782
- pathMappings,
783
- bindingValues,
784
- });
785
- return transformed === undefined ? [] : [transformed];
786
- });
787
- }
788
- if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
789
- const actual = Object.keys(value).sort();
790
- const expected = Object.keys(declaration.recipe.fields);
791
- if (actual.some((field) => !expected.includes(field))) {
792
- throw new Error(`${path} contains an unclassified JSON key.`);
793
- }
794
- const missing = expected.filter(
795
- (field) =>
796
- declaration.recipe.fields[field].required && !Object.hasOwn(value, field),
797
- );
798
- if (missing.length) throw new Error(`${path} is missing required JSON keys.`);
799
- return Object.fromEntries(
800
- Object.entries(declaration.recipe.fields).flatMap(([field, nested]) => {
801
- if (!Object.hasOwn(value, field)) return [];
802
- const transformed = executeDeclaration({
803
- declaration: nested,
804
- value: value[field],
805
- key,
806
- path: `${path}.${field}`,
807
- ownerApproved,
808
- pathMappings,
809
- bindingValues,
810
- });
811
- return transformed === undefined ? [] : [[field, transformed]];
812
- }),
813
- );
814
- };
815
-
816
- export const createPrivacyEngine = ({
817
- policy,
818
- key,
819
- environment = process.env,
820
- }) => {
821
- const normalized = validateExecutablePrivacyPolicy(policy);
822
- const secret = Buffer.isBuffer(key) ? key : Buffer.from(key ?? "");
823
- if (secret.length < KEY_BYTES) {
824
- throw new Error(`Privacy key must contain at least ${KEY_BYTES} bytes.`);
825
- }
826
- const bindingValues = new Map(
827
- Object.entries(normalized.bindings).map(([name, binding]) => {
828
- const value = environment[binding.environmentVariable];
829
- if (typeof value !== "string" || !value) {
830
- throw new Error(
831
- `Privacy binding ${name} requires ${binding.environmentVariable}.`,
832
- );
833
- }
834
- const receipt = createHash("sha256").update(value).digest("hex");
835
- if (receipt !== binding.approvedValueSha256) {
836
- throw new Error(
837
- `Privacy binding ${name} does not match its review receipt.`,
838
- );
839
- }
840
- return [name, value];
841
- }),
842
- );
843
- const tables = new Map(
844
- normalized.tables.map((table) => [
845
- table.schema === "public" ? table.name : `${table.schema}.${table.name}`,
846
- table,
847
- ]),
848
- );
849
- return Object.freeze({
850
- keyFingerprint: createHash("sha256").update(secret).digest("hex"),
851
- remapPath({ mapping: mappingName, value }) {
852
- const mapping = normalized.pathMappings[mappingName];
853
- if (!mapping) {
854
- throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
855
- }
856
- return remapPath({
857
- key: secret,
858
- mapping,
859
- bindingValue: bindingValues.get(mapping.binding),
860
- value,
861
- label: `Privacy path mapping ${mappingName}`,
862
- });
863
- },
864
- sanitize(record) {
865
- if (
866
- !isObject(record) ||
867
- !IDENTIFIER.test(record.schema ?? "public") ||
868
- !IDENTIFIER.test(record.table ?? "") ||
869
- !isObject(record.row)
870
- ) {
871
- throw new Error("Source record has an invalid privacy-engine shape.");
872
- }
873
- const schema = record.schema ?? "public";
874
- const relation =
875
- schema === "public" ? record.table : `${schema}.${record.table}`;
876
- const table = tables.get(relation);
877
- if (!table)
878
- throw new Error(`Privacy policy does not classify table ${relation}.`);
879
- if (table.sourceRows === "EXCLUDE") return null;
880
- const actual = Object.keys(record.row).sort();
881
- const expected = table.columns.map((column) => column.name).sort();
882
- if (actual.join("\0") !== expected.join("\0")) {
883
- throw new Error(
884
- `Source table ${relation} contains an unclassified or missing column.`,
885
- );
886
- }
887
- let ownerApproved;
888
- if (table.ownerBinding) {
889
- const owner = record.row[table.ownerBinding.column];
890
- if (!["string", "number"].includes(typeof owner) || owner === "") {
891
- throw new Error(`${relation} has an invalid owner binding value.`);
892
- }
893
- ownerApproved =
894
- String(owner) === bindingValues.get(table.ownerBinding.binding);
895
- }
896
- const row = Object.fromEntries(
897
- table.columns.flatMap((column) => {
898
- const transformed = executeDeclaration({
899
- declaration: column,
900
- value: record.row[column.name],
901
- key: secret,
902
- path: `${relation}.${column.name}`,
903
- ownerApproved,
904
- pathMappings: normalized.pathMappings,
905
- bindingValues,
906
- });
907
- return transformed === undefined ? [] : [[column.name, transformed]];
908
- }),
909
- );
910
- return schema === "public"
911
- ? { table: record.table, row }
912
- : { schema, table: record.table, row };
913
- },
914
- });
915
- };
916
-
917
- export const createPrivacyKey = async (path) => {
918
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
919
- const key = randomBytes(KEY_BYTES);
920
- const handle = await open(path, "wx", 0o600);
921
- try {
922
- await handle.writeFile(`${key.toString("base64")}\n`);
923
- await handle.sync();
924
- } finally {
925
- await handle.close();
926
- }
927
- await chmod(path, 0o600);
928
- return { fingerprint: createHash("sha256").update(key).digest("hex") };
929
- };
930
-
931
- export const readPrivacyKey = async (path) => {
932
- const details = await stat(path);
933
- if (!details.isFile() || (details.mode & 0o077) !== 0) {
934
- throw new Error("Privacy key must be an owner-only regular file.");
935
- }
936
- const encoded = (await readFile(path, "utf8")).trim();
937
- const key = Buffer.from(encoded, "base64");
938
- if (key.length !== KEY_BYTES || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
939
- throw new Error("Privacy key file is invalid.");
940
- }
941
- return key;
942
- };
943
-
944
- export const assertPrivacyKeyFingerprint = (key, expected) => {
945
- if (!HEX_64.test(expected ?? ""))
946
- throw new Error("Privacy key fingerprint is invalid.");
947
- const actual = createHash("sha256").update(key).digest("hex");
948
- if (actual !== expected)
949
- throw new Error(
950
- "Privacy key does not match the reviewed baseline receipt.",
951
- );
952
- };