@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14

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 (38) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +5 -3
  4. package/docs/commands.md +15 -11
  5. package/docs/configuration.md +40 -5
  6. package/docs/getting-started.md +32 -3
  7. package/docs/production-source.md +175 -17
  8. package/docs/releasing.md +5 -3
  9. package/docs/roadmap.md +3 -2
  10. package/docs/sanitization.md +479 -12
  11. package/docs/security-model.md +8 -0
  12. package/docs/standalone-workflow.md +34 -13
  13. package/docs/troubleshooting.md +17 -6
  14. package/docs/tutorial.md +7 -1
  15. package/package.json +2 -2
  16. package/scripts/install/create_root_config.mjs +5 -22
  17. package/src/baseline/artifact.mjs +27 -4
  18. package/src/baseline/privacy_engine.mjs +865 -207
  19. package/src/baseline/privacy_structured_json.mjs +554 -0
  20. package/src/cli/arguments.mjs +3 -0
  21. package/src/cli/guided.mjs +8 -6
  22. package/src/cli/rehearsal.mjs +23 -3
  23. package/src/cli/renderers.mjs +29 -4
  24. package/src/cli/runtime_commands.mjs +92 -9
  25. package/src/cli/source_commands.mjs +37 -8
  26. package/src/identity/claim.mjs +23 -21
  27. package/src/project/install_scaffold.mjs +35 -1
  28. package/src/project/setup.mjs +14 -24
  29. package/src/runtime/plan.mjs +7 -3
  30. package/src/runtime/restore.mjs +13 -5
  31. package/src/shared/diagnostics.mjs +7 -0
  32. package/src/shared/node_runtime.mjs +31 -0
  33. package/src/source/access.mjs +271 -51
  34. package/src/source/asset_transfer.mjs +156 -74
  35. package/src/source/baseline.mjs +42 -6
  36. package/src/source/external_reader.mjs +368 -0
  37. package/src/source/postgresql_access.mjs +213 -55
  38. package/src/source/privacy_paths.mjs +38 -0
@@ -7,11 +7,20 @@
7
7
  import { createHash, createHmac, randomBytes } from "node:crypto";
8
8
  import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
9
9
  import { dirname } from "node:path";
10
+ import {
11
+ STRUCTURED_JSON_KINDS,
12
+ executeStructuredJsonDeclaration,
13
+ structuredJsonDeclarations,
14
+ validateStructuredJsonRecipe,
15
+ } from "./privacy_structured_json.mjs";
10
16
 
11
17
  const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
12
18
  const BINDING_NAME = /^[a-z][a-z0-9-]{0,62}$/u;
13
19
  const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
14
20
  const HEX_64 = /^[a-f0-9]{64}$/u;
21
+ const UUID =
22
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
23
+ const PORTABLE_CODE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
15
24
  const ACTIONS = new Set([
16
25
  "KEEP",
17
26
  "PSEUDONYMIZE",
@@ -19,17 +28,42 @@ const ACTIONS = new Set([
19
28
  "EXCLUDE",
20
29
  "DERIVE",
21
30
  ]);
22
- const PSEUDONYM_FORMATS = new Set(["uuid", "email", "text", "integer"]);
31
+ const PSEUDONYM_FORMATS = new Set([
32
+ "uuid",
33
+ "email",
34
+ "text",
35
+ "integer",
36
+ "hex",
37
+ "gtin",
38
+ "url",
39
+ ]);
40
+ const HEX_LENGTHS = new Set([32, 64]);
41
+ const GTIN_LENGTHS = new Set([8, 12, 13, 14]);
42
+ const DATE_REPRESENTATIONS = new Set(["iso-string", "epoch-milliseconds"]);
23
43
  const DERIVATION_KINDS = new Set([
24
44
  "date-shift",
45
+ "digest",
46
+ "enum",
47
+ "validated-string",
48
+ "path-map",
25
49
  "json-object",
26
50
  "json-array",
51
+ "json-union",
52
+ "json-dictionary",
27
53
  "approved-owner",
54
+ "binding-substitute",
28
55
  ]);
29
56
  const KEY_BYTES = 32;
30
- const DEFAULT_JSON_MAXIMUM_BYTES = 65_536;
31
- const DEFAULT_JSON_MAXIMUM_DEPTH = 8;
57
+ const URL_PATH_PREFIX = "/rehearsal/";
58
+ const URL_TOKEN_LENGTH = 32;
59
+ const MAXIMUM_URL_LENGTH = 2_048;
60
+ const MAXIMUM_TEXT_SUBSTITUTION_BYTES = 65_536;
61
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
62
+ const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
63
+ const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
32
64
  const MAXIMUM_POLICY_DEPTH = 16;
65
+ const MAXIMUM_ENUM_VALUES = 128;
66
+ const MAXIMUM_ENUM_STRING_BYTES = 256;
33
67
 
34
68
  const isObject = (value) =>
35
69
  value !== null &&
@@ -64,6 +98,51 @@ const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
64
98
  return value;
65
99
  };
66
100
 
101
+ const reviewedScalar = (value, label) => {
102
+ if (typeof value === "string") {
103
+ if (Buffer.byteLength(value) > MAXIMUM_ENUM_STRING_BYTES) {
104
+ throw new Error(
105
+ `${label} must contain at most ${MAXIMUM_ENUM_STRING_BYTES} UTF-8 bytes.`,
106
+ );
107
+ }
108
+ return value;
109
+ }
110
+ if (typeof value === "boolean") return value;
111
+ if (
112
+ typeof value === "number" &&
113
+ Number.isFinite(value) &&
114
+ !Object.is(value, -0)
115
+ ) {
116
+ return value;
117
+ }
118
+ throw new Error(`${label} must be a JSON string, finite number, or boolean.`);
119
+ };
120
+
121
+ const httpsLoopbackOrigin = (value, label) => {
122
+ if (typeof value !== "string" || value.trim() !== value || !value) {
123
+ throw new Error(`${label} must be a safe HTTPS loopback origin.`);
124
+ }
125
+ let parsed;
126
+ try {
127
+ parsed = new URL(value);
128
+ } catch {
129
+ throw new Error(`${label} must be a safe HTTPS loopback origin.`);
130
+ }
131
+ if (
132
+ parsed.protocol !== "https:" ||
133
+ !LOOPBACK_HOSTNAMES.has(parsed.hostname) ||
134
+ ![parsed.origin, `${parsed.origin}/`].includes(value) ||
135
+ parsed.username ||
136
+ parsed.password ||
137
+ (parsed.pathname !== "/" && parsed.pathname !== "") ||
138
+ parsed.search ||
139
+ parsed.hash
140
+ ) {
141
+ throw new Error(`${label} must be a safe HTTPS loopback origin.`);
142
+ }
143
+ return parsed.origin;
144
+ };
145
+
67
146
  const validateNestedDeclaration = (
68
147
  declaration,
69
148
  label,
@@ -105,7 +184,18 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
105
184
  throw new Error(`${label}.recipe is required for ${action}.`);
106
185
  }
107
186
  if (action === "PSEUDONYMIZE") {
108
- knownKeys(recipe, ["format", "namespace", "maxLength"], `${label}.recipe`);
187
+ knownKeys(
188
+ recipe,
189
+ [
190
+ "format",
191
+ "namespace",
192
+ "maxLength",
193
+ "length",
194
+ "allowedLengths",
195
+ "origin",
196
+ ],
197
+ `${label}.recipe`,
198
+ );
109
199
  if (!PSEUDONYM_FORMATS.has(recipe.format)) {
110
200
  throw new Error(`${label}.recipe.format is unsupported.`);
111
201
  }
@@ -119,9 +209,74 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
119
209
  `${label}.recipe.maxLength`,
120
210
  1_024,
121
211
  );
212
+ } else if (recipe.format === "url") {
213
+ normalized.origin = httpsLoopbackOrigin(
214
+ recipe.origin,
215
+ `${label}.recipe.origin`,
216
+ );
217
+ normalized.maxLength = positiveInteger(
218
+ recipe.maxLength,
219
+ `${label}.recipe.maxLength`,
220
+ MAXIMUM_URL_LENGTH,
221
+ );
222
+ const outputLength =
223
+ normalized.origin.length + URL_PATH_PREFIX.length + URL_TOKEN_LENGTH;
224
+ if (normalized.maxLength < outputLength) {
225
+ throw new Error(
226
+ `${label}.recipe.maxLength must allow the complete pseudonymous URL (${outputLength} characters).`,
227
+ );
228
+ }
122
229
  } else if (recipe.maxLength !== undefined) {
123
230
  throw new Error(
124
- `${label}.recipe.maxLength is supported only for text pseudonyms.`,
231
+ `${label}.recipe.maxLength is supported only for text and url pseudonyms.`,
232
+ );
233
+ }
234
+ if (recipe.format === "hex") {
235
+ if (!HEX_LENGTHS.has(recipe.length)) {
236
+ throw new Error(`${label}.recipe.length must be 32 or 64 for hex.`);
237
+ }
238
+ normalized.length = recipe.length;
239
+ } else if (recipe.format === "gtin") {
240
+ const hasLength = Object.hasOwn(recipe, "length");
241
+ const hasAllowedLengths = Object.hasOwn(recipe, "allowedLengths");
242
+ if (hasLength === hasAllowedLengths) {
243
+ throw new Error(
244
+ `${label}.recipe must declare exactly one of length or allowedLengths for gtin.`,
245
+ );
246
+ }
247
+ if (hasLength) {
248
+ if (!GTIN_LENGTHS.has(recipe.length)) {
249
+ throw new Error(
250
+ `${label}.recipe.length must be 8, 12, 13, or 14 for gtin.`,
251
+ );
252
+ }
253
+ normalized.length = recipe.length;
254
+ } else {
255
+ if (
256
+ !Array.isArray(recipe.allowedLengths) ||
257
+ recipe.allowedLengths.length === 0 ||
258
+ recipe.allowedLengths.some((length) => !GTIN_LENGTHS.has(length))
259
+ ) {
260
+ throw new Error(
261
+ `${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`,
262
+ );
263
+ }
264
+ if (
265
+ new Set(recipe.allowedLengths).size !== recipe.allowedLengths.length
266
+ ) {
267
+ throw new Error(
268
+ `${label}.recipe.allowedLengths must not contain duplicates.`,
269
+ );
270
+ }
271
+ normalized.allowedLengths = Object.freeze([...recipe.allowedLengths]);
272
+ }
273
+ } else if (
274
+ recipe.length !== undefined ||
275
+ recipe.allowedLengths !== undefined ||
276
+ (recipe.origin !== undefined && recipe.format !== "url")
277
+ ) {
278
+ throw new Error(
279
+ `${label}.recipe shape controls do not match its pseudonym format.`,
125
280
  );
126
281
  }
127
282
  return Object.freeze(normalized);
@@ -142,8 +297,19 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
142
297
  [
143
298
  "kind",
144
299
  "days",
300
+ "group",
301
+ "representation",
302
+ "format",
303
+ "length",
304
+ "namespace",
305
+ "inputs",
306
+ "mapping",
307
+ "binding",
145
308
  "fields",
146
309
  "items",
310
+ "variants",
311
+ "keys",
312
+ "values",
147
313
  "approved",
148
314
  "otherwise",
149
315
  "allowNull",
@@ -156,14 +322,184 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
156
322
  if (!DERIVATION_KINDS.has(recipe.kind)) {
157
323
  throw new Error(`${label}.recipe.kind is unsupported.`);
158
324
  }
325
+ if (recipe.kind === "enum") {
326
+ if (
327
+ Object.keys(recipe).some(
328
+ (key) => !["kind", "values", "allowNull"].includes(key),
329
+ )
330
+ ) {
331
+ throw new Error(`${label}.recipe contains fields not used by enum.`);
332
+ }
333
+ if (
334
+ !Array.isArray(recipe.values) ||
335
+ recipe.values.length === 0 ||
336
+ recipe.values.length > MAXIMUM_ENUM_VALUES
337
+ ) {
338
+ throw new Error(
339
+ `${label}.recipe.values must contain 1 through ${MAXIMUM_ENUM_VALUES} reviewed scalars.`,
340
+ );
341
+ }
342
+ if (
343
+ recipe.allowNull !== undefined &&
344
+ typeof recipe.allowNull !== "boolean"
345
+ ) {
346
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
347
+ }
348
+ const values = Array.from(recipe.values, (value, index) =>
349
+ reviewedScalar(value, `${label}.recipe.values[${index}]`),
350
+ );
351
+ const identities = values.map((value) =>
352
+ JSON.stringify([typeof value, value]),
353
+ );
354
+ if (new Set(identities).size !== identities.length) {
355
+ throw new Error(`${label}.recipe.values must not contain duplicates.`);
356
+ }
357
+ return Object.freeze({
358
+ kind: "enum",
359
+ allowNull: recipe.allowNull === true,
360
+ values: Object.freeze(values),
361
+ });
362
+ }
363
+ if (recipe.kind === "validated-string") {
364
+ if (
365
+ Object.keys(recipe).some(
366
+ (key) => !["kind", "format", "maximumBytes", "allowNull"].includes(key),
367
+ )
368
+ ) {
369
+ throw new Error(
370
+ `${label}.recipe contains fields not used by validated-string.`,
371
+ );
372
+ }
373
+ if (recipe.format !== "portable-code") {
374
+ throw new Error(
375
+ `${label}.recipe.format must be portable-code for validated-string.`,
376
+ );
377
+ }
378
+ const maximumBytes = positiveInteger(
379
+ recipe.maximumBytes,
380
+ `${label}.recipe.maximumBytes`,
381
+ 1_024,
382
+ );
383
+ if (
384
+ recipe.allowNull !== undefined &&
385
+ typeof recipe.allowNull !== "boolean"
386
+ ) {
387
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
388
+ }
389
+ return Object.freeze({
390
+ kind: "validated-string",
391
+ format: "portable-code",
392
+ maximumBytes,
393
+ allowNull: recipe.allowNull === true,
394
+ });
395
+ }
396
+ if (recipe.kind === "digest") {
397
+ if (
398
+ Object.keys(recipe).some(
399
+ (key) =>
400
+ !["kind", "format", "length", "namespace", "inputs"].includes(key),
401
+ )
402
+ ) {
403
+ throw new Error(`${label}.recipe contains fields not used by digest.`);
404
+ }
405
+ if (recipe.format !== "hex") {
406
+ throw new Error(`${label}.recipe.format must be hex for digest.`);
407
+ }
408
+ if (!HEX_LENGTHS.has(recipe.length)) {
409
+ throw new Error(`${label}.recipe.length must be 32 or 64 for digest.`);
410
+ }
411
+ if (
412
+ !Array.isArray(recipe.inputs) ||
413
+ recipe.inputs.length === 0 ||
414
+ recipe.inputs.length > 32
415
+ ) {
416
+ throw new Error(
417
+ `${label}.recipe.inputs must contain 1 through 32 columns.`,
418
+ );
419
+ }
420
+ const inputs = recipe.inputs.map((input, index) =>
421
+ identifier(input, `${label}.recipe.inputs[${index}]`),
422
+ );
423
+ if (new Set(inputs).size !== inputs.length) {
424
+ throw new Error(`${label}.recipe.inputs must not contain duplicates.`);
425
+ }
426
+ return Object.freeze({
427
+ kind: "digest",
428
+ format: "hex",
429
+ length: recipe.length,
430
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
431
+ inputs: Object.freeze(inputs),
432
+ });
433
+ }
159
434
  if (recipe.kind === "date-shift") {
160
435
  const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
161
- if (Object.keys(recipe).some((key) => !["kind", "days"].includes(key))) {
436
+ if (
437
+ Object.keys(recipe).some(
438
+ (key) => !["kind", "days", "group", "representation"].includes(key),
439
+ )
440
+ ) {
162
441
  throw new Error(
163
442
  `${label}.recipe contains fields not used by date-shift.`,
164
443
  );
165
444
  }
166
- return Object.freeze({ kind: "date-shift", days });
445
+ if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
446
+ throw new Error(`${label}.recipe.group is invalid.`);
447
+ }
448
+ const representation =
449
+ recipe.representation === undefined
450
+ ? "iso-string"
451
+ : recipe.representation;
452
+ if (!DATE_REPRESENTATIONS.has(representation)) {
453
+ throw new Error(
454
+ `${label}.recipe.representation must be iso-string or epoch-milliseconds.`,
455
+ );
456
+ }
457
+ return Object.freeze({
458
+ kind: "date-shift",
459
+ days,
460
+ representation,
461
+ ...(recipe.group === undefined ? {} : { group: recipe.group }),
462
+ });
463
+ }
464
+ if (recipe.kind === "path-map") {
465
+ if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
466
+ throw new Error(`${label}.recipe contains fields not used by path-map.`);
467
+ }
468
+ if (!BINDING_NAME.test(recipe.mapping ?? "")) {
469
+ throw new Error(`${label}.recipe.mapping is invalid.`);
470
+ }
471
+ return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
472
+ }
473
+ if (recipe.kind === "binding-substitute") {
474
+ if (
475
+ Object.keys(recipe).some(
476
+ (key) =>
477
+ !["kind", "binding", "format", "namespace", "maximumBytes"].includes(
478
+ key,
479
+ ),
480
+ )
481
+ ) {
482
+ throw new Error(
483
+ `${label}.recipe contains fields not used by binding-substitute.`,
484
+ );
485
+ }
486
+ if (!BINDING_NAME.test(recipe.binding ?? "")) {
487
+ throw new Error(`${label}.recipe.binding is invalid.`);
488
+ }
489
+ if (recipe.format !== "uuid") {
490
+ throw new Error(`${label}.recipe.format must be uuid.`);
491
+ }
492
+ return Object.freeze({
493
+ kind: "binding-substitute",
494
+ binding: recipe.binding,
495
+ format: "uuid",
496
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
497
+ maximumBytes: positiveInteger(
498
+ recipe.maximumBytes,
499
+ `${label}.recipe.maximumBytes`,
500
+ MAXIMUM_TEXT_SUBSTITUTION_BYTES,
501
+ ),
502
+ });
167
503
  }
168
504
  if (recipe.kind === "approved-owner") {
169
505
  if (
@@ -189,90 +525,14 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
189
525
  ),
190
526
  });
191
527
  }
192
- if (recipe.allowNull !== undefined && typeof recipe.allowNull !== "boolean") {
193
- throw new Error(`${label}.recipe.allowNull must be true or false.`);
194
- }
195
- const bounds = Object.freeze({
196
- maximumBytes: positiveInteger(
197
- recipe.maximumBytes ?? DEFAULT_JSON_MAXIMUM_BYTES,
198
- `${label}.recipe.maximumBytes`,
199
- 16_777_216,
200
- ),
201
- maximumDepth: positiveInteger(
202
- recipe.maximumDepth ?? DEFAULT_JSON_MAXIMUM_DEPTH,
203
- `${label}.recipe.maximumDepth`,
204
- MAXIMUM_POLICY_DEPTH,
205
- ),
206
- });
207
- if (recipe.kind === "json-array") {
208
- if (
209
- Object.keys(recipe).some(
210
- (key) =>
211
- ![
212
- "kind",
213
- "items",
214
- "allowNull",
215
- "maximumBytes",
216
- "maximumDepth",
217
- "maximumItems",
218
- ].includes(key),
219
- )
220
- ) {
221
- throw new Error(
222
- `${label}.recipe contains fields not used by json-array.`,
223
- );
224
- }
225
- return Object.freeze({
226
- kind: "json-array",
227
- allowNull: recipe.allowNull === true,
228
- ...bounds,
229
- maximumItems: positiveInteger(
230
- recipe.maximumItems,
231
- `${label}.recipe.maximumItems`,
232
- 10_000,
233
- ),
234
- items: validateNestedDeclaration(recipe.items, `${label}.recipe.items`, {
235
- depth,
236
- }),
237
- });
238
- }
239
- if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
240
- throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
241
- }
242
- if (
243
- Object.keys(recipe).some(
244
- (key) =>
245
- ![
246
- "kind",
247
- "fields",
248
- "allowNull",
249
- "maximumBytes",
250
- "maximumDepth",
251
- ].includes(key),
252
- )
253
- ) {
254
- throw new Error(`${label}.recipe contains fields not used by json-object.`);
255
- }
256
- const fields = Object.fromEntries(
257
- Object.entries(recipe.fields).map(([key, declaration]) => {
258
- if (!key) {
259
- throw new Error(`${label}.recipe.fields contains an invalid key.`);
260
- }
261
- return [
262
- key,
263
- validateNestedDeclaration(
264
- declaration,
265
- `${label}.recipe.fields.${key}`,
266
- { allowRequired: true, depth },
267
- ),
268
- ];
269
- }),
270
- );
271
- return Object.freeze({
272
- kind: "json-object",
273
- allowNull: recipe.allowNull === true,
274
- ...bounds,
275
- fields: Object.freeze(fields),
528
+ return validateStructuredJsonRecipe({
529
+ recipe,
530
+ label,
531
+ depth,
532
+ maximumPolicyDepth: MAXIMUM_POLICY_DEPTH,
533
+ validateDeclaration: validateNestedDeclaration,
534
+ validatePseudonymRecipe: (nestedRecipe, nestedLabel, nestedDepth) =>
535
+ validateRecipe("PSEUDONYMIZE", nestedRecipe, nestedLabel, nestedDepth),
276
536
  });
277
537
  };
278
538
 
@@ -280,7 +540,14 @@ export const validateExecutablePrivacyPolicy = (policy) => {
280
540
  if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
281
541
  knownKeys(
282
542
  policy,
283
- ["policyVersion", "draft", "migrationCutoff", "bindings", "tables"],
543
+ [
544
+ "policyVersion",
545
+ "draft",
546
+ "migrationCutoff",
547
+ "bindings",
548
+ "pathMappings",
549
+ "tables",
550
+ ],
284
551
  "policy",
285
552
  );
286
553
  if (policy.policyVersion !== 2) {
@@ -323,6 +590,60 @@ export const validateExecutablePrivacyPolicy = (policy) => {
323
590
  return [name, Object.freeze({ ...binding })];
324
591
  }),
325
592
  );
593
+ if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
594
+ throw new Error("policy.pathMappings must be an object.");
595
+ }
596
+ const pathMappings = Object.fromEntries(
597
+ Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
598
+ const label = `policy.pathMappings.${name}`;
599
+ if (!BINDING_NAME.test(name) || !isObject(mapping)) {
600
+ throw new Error("policy.pathMappings contains an invalid mapping.");
601
+ }
602
+ knownKeys(
603
+ mapping,
604
+ [
605
+ "binding",
606
+ "format",
607
+ "namespace",
608
+ "maxLength",
609
+ "maximumBytes",
610
+ "maximumSegments",
611
+ ],
612
+ label,
613
+ );
614
+ if (!Object.hasOwn(bindings, mapping.binding ?? "")) {
615
+ throw new Error(`${label}.binding references an unknown binding.`);
616
+ }
617
+ if (!["uuid", "text"].includes(mapping.format)) {
618
+ throw new Error(`${label}.format must be uuid or text.`);
619
+ }
620
+ const normalized = {
621
+ binding: mapping.binding,
622
+ format: mapping.format,
623
+ namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
624
+ maximumBytes: positiveInteger(
625
+ mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES,
626
+ `${label}.maximumBytes`,
627
+ 16_384,
628
+ ),
629
+ maximumSegments: positiveInteger(
630
+ mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS,
631
+ `${label}.maximumSegments`,
632
+ 256,
633
+ ),
634
+ };
635
+ if (mapping.format === "text") {
636
+ normalized.maxLength = positiveInteger(
637
+ mapping.maxLength,
638
+ `${label}.maxLength`,
639
+ 1_024,
640
+ );
641
+ } else if (mapping.maxLength !== undefined) {
642
+ throw new Error(`${label}.maxLength is supported only for text.`);
643
+ }
644
+ return [name, Object.freeze(normalized)];
645
+ }),
646
+ );
326
647
  const names = new Set();
327
648
  const tables = policy.tables.map((table, tableIndex) => {
328
649
  const label = `policy.tables[${tableIndex}]`;
@@ -405,43 +726,169 @@ export const validateExecutablePrivacyPolicy = (policy) => {
405
726
  }
406
727
  knownKeys(
407
728
  table.ownerBinding,
408
- ["binding", "column"],
729
+ ["binding", "column", "columns", "match", "nullBehavior"],
409
730
  `${label}.ownerBinding`,
410
731
  );
411
732
  const binding = nonEmpty(
412
733
  table.ownerBinding.binding,
413
734
  `${label}.ownerBinding.binding`,
414
735
  );
415
- const column = identifier(
416
- table.ownerBinding.column,
417
- `${label}.ownerBinding.column`,
418
- );
419
- if (!Object.hasOwn(bindings, binding)) {
420
- throw new Error(`${label}.ownerBinding references an unknown binding.`);
736
+ const hasColumn = table.ownerBinding.column !== undefined;
737
+ const hasColumns = table.ownerBinding.columns !== undefined;
738
+ if (hasColumn === hasColumns) {
739
+ throw new Error(
740
+ `${label}.ownerBinding must declare exactly one of column or columns.`,
741
+ );
421
742
  }
422
- if (!columnNames.has(column)) {
423
- throw new Error(`${label}.ownerBinding references an unknown column.`);
743
+ let ownerColumns;
744
+ if (hasColumn) {
745
+ if (table.ownerBinding.match !== undefined) {
746
+ throw new Error(
747
+ `${label}.ownerBinding.match is supported only with columns.`,
748
+ );
749
+ }
750
+ ownerColumns = [
751
+ identifier(table.ownerBinding.column, `${label}.ownerBinding.column`),
752
+ ];
753
+ } else {
754
+ if (
755
+ !Array.isArray(table.ownerBinding.columns) ||
756
+ table.ownerBinding.columns.length === 0 ||
757
+ table.ownerBinding.columns.length > 16
758
+ ) {
759
+ throw new Error(
760
+ `${label}.ownerBinding.columns must contain 1 through 16 columns.`,
761
+ );
762
+ }
763
+ if (table.ownerBinding.match !== "any") {
764
+ throw new Error(
765
+ `${label}.ownerBinding.match must be any when columns are used.`,
766
+ );
767
+ }
768
+ ownerColumns = table.ownerBinding.columns.map((column, index) =>
769
+ identifier(column, `${label}.ownerBinding.columns[${index}]`),
770
+ );
771
+ if (new Set(ownerColumns).size !== ownerColumns.length) {
772
+ throw new Error(
773
+ `${label}.ownerBinding.columns must not contain duplicates.`,
774
+ );
775
+ }
424
776
  }
425
- const ownerColumn = columns.find((entry) => entry.name === column);
426
777
  if (
427
- ownerColumn.action !== "PSEUDONYMIZE" ||
428
- ownerColumn.identity !== "YES"
778
+ table.ownerBinding.nullBehavior !== undefined &&
779
+ table.ownerBinding.nullBehavior !== "otherwise"
429
780
  ) {
430
781
  throw new Error(
431
- `${label}.ownerBinding column must be an explicitly pseudonymized identity.`,
782
+ `${label}.ownerBinding.nullBehavior must be otherwise when declared.`,
432
783
  );
433
784
  }
434
- ownerBinding = Object.freeze({ binding, column });
785
+ if (!Object.hasOwn(bindings, binding)) {
786
+ throw new Error(`${label}.ownerBinding references an unknown binding.`);
787
+ }
788
+ for (const column of ownerColumns) {
789
+ if (!columnNames.has(column)) {
790
+ throw new Error(
791
+ `${label}.ownerBinding references an unknown column.`,
792
+ );
793
+ }
794
+ const ownerColumn = columns.find((entry) => entry.name === column);
795
+ if (ownerColumn.action !== "PSEUDONYMIZE") {
796
+ throw new Error(
797
+ `${label}.ownerBinding columns must be explicitly pseudonymized.`,
798
+ );
799
+ }
800
+ }
801
+ ownerBinding = Object.freeze({
802
+ binding,
803
+ columns: Object.freeze(ownerColumns),
804
+ match: "any",
805
+ ...(table.ownerBinding.nullBehavior === undefined
806
+ ? {}
807
+ : { nullBehavior: table.ownerBinding.nullBehavior }),
808
+ });
435
809
  }
436
810
  const usesApprovedOwner = (declaration) =>
437
811
  declaration.recipe?.kind === "approved-owner" ||
438
- (declaration.recipe?.kind === "json-object" &&
439
- Object.values(declaration.recipe.fields).some(usesApprovedOwner)) ||
440
- (declaration.recipe?.kind === "json-array" &&
441
- usesApprovedOwner(declaration.recipe.items));
812
+ structuredJsonDeclarations(declaration.recipe).some(usesApprovedOwner);
442
813
  if (columns.some(usesApprovedOwner) && !ownerBinding) {
443
814
  throw new Error(`${label} uses approved-owner without ownerBinding.`);
444
815
  }
816
+ const visitRecipes = (declaration, visit) => {
817
+ visit(declaration.recipe);
818
+ if (declaration.recipe?.kind === "approved-owner") {
819
+ visitRecipes(declaration.recipe.approved, visit);
820
+ visitRecipes(declaration.recipe.otherwise, visit);
821
+ } else {
822
+ structuredJsonDeclarations(declaration.recipe).forEach((nested) =>
823
+ visitRecipes(nested, visit),
824
+ );
825
+ }
826
+ };
827
+ for (const column of columns) {
828
+ visitRecipes(column, (recipe) => {
829
+ if (
830
+ recipe?.kind === "path-map" &&
831
+ !Object.hasOwn(pathMappings, recipe.mapping)
832
+ ) {
833
+ throw new Error(
834
+ `${label} references unknown path mapping ${recipe.mapping}.`,
835
+ );
836
+ }
837
+ if (
838
+ recipe?.kind === "binding-substitute" &&
839
+ !Object.hasOwn(bindings, recipe.binding)
840
+ ) {
841
+ throw new Error(
842
+ `${label} references unknown privacy binding ${recipe.binding}.`,
843
+ );
844
+ }
845
+ });
846
+ }
847
+ const dependencies = new Map(columns.map((column) => [column.name, []]));
848
+ const visitDigestInputs = (declaration) => {
849
+ if (declaration.recipe?.kind === "digest") {
850
+ return [...declaration.recipe.inputs];
851
+ }
852
+ if (declaration.recipe?.kind === "approved-owner") {
853
+ return [
854
+ ...visitDigestInputs(declaration.recipe.approved),
855
+ ...visitDigestInputs(declaration.recipe.otherwise),
856
+ ];
857
+ }
858
+ return structuredJsonDeclarations(declaration.recipe).flatMap(
859
+ visitDigestInputs,
860
+ );
861
+ };
862
+ for (const column of columns) {
863
+ const inputs = [...new Set(visitDigestInputs(column))];
864
+ for (const input of inputs) {
865
+ const inputColumn = columns.find((entry) => entry.name === input);
866
+ if (!inputColumn) {
867
+ throw new Error(
868
+ `${label}.${column.name} digest references unknown column ${input}.`,
869
+ );
870
+ }
871
+ if (inputColumn.action === "EXCLUDE") {
872
+ throw new Error(
873
+ `${label}.${column.name} digest input ${input} is excluded instead of sanitized.`,
874
+ );
875
+ }
876
+ }
877
+ dependencies.set(column.name, inputs);
878
+ }
879
+ const resolvedDependencies = new Set();
880
+ const resolvingDependencies = new Set();
881
+ const visitDependency = (columnName) => {
882
+ if (resolvedDependencies.has(columnName)) return;
883
+ if (resolvingDependencies.has(columnName)) {
884
+ throw new Error(`${label} contains a cyclic digest dependency.`);
885
+ }
886
+ resolvingDependencies.add(columnName);
887
+ for (const input of dependencies.get(columnName)) visitDependency(input);
888
+ resolvingDependencies.delete(columnName);
889
+ resolvedDependencies.add(columnName);
890
+ };
891
+ columns.forEach((column) => visitDependency(column.name));
445
892
  return Object.freeze({
446
893
  schema,
447
894
  name,
@@ -450,10 +897,31 @@ export const validateExecutablePrivacyPolicy = (policy) => {
450
897
  columns: Object.freeze(columns),
451
898
  });
452
899
  });
900
+ const dateShiftGroups = new Map();
901
+ const visitDateShiftGroups = (declaration) => {
902
+ const recipe = declaration.recipe;
903
+ if (recipe?.kind === "date-shift" && recipe.group) {
904
+ const existing = dateShiftGroups.get(recipe.group);
905
+ if (existing !== undefined && existing !== recipe.days) {
906
+ throw new Error(
907
+ `Privacy date-shift group ${recipe.group} must use one days value.`,
908
+ );
909
+ }
910
+ dateShiftGroups.set(recipe.group, recipe.days);
911
+ }
912
+ if (recipe?.kind === "approved-owner") {
913
+ visitDateShiftGroups(recipe.approved);
914
+ visitDateShiftGroups(recipe.otherwise);
915
+ } else {
916
+ structuredJsonDeclarations(recipe).forEach(visitDateShiftGroups);
917
+ }
918
+ };
919
+ tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
453
920
  return Object.freeze({
454
921
  policyVersion: 2,
455
922
  migrationCutoff: policy.migrationCutoff,
456
923
  bindings: Object.freeze(bindings),
924
+ pathMappings: Object.freeze(pathMappings),
457
925
  tables: Object.freeze(tables),
458
926
  });
459
927
  };
@@ -465,6 +933,23 @@ const digest = (key, namespace, value) =>
465
933
  .update(JSON.stringify(value))
466
934
  .digest();
467
935
 
936
+ const gtinCheckDigit = (payload) => {
937
+ const sum = [...payload]
938
+ .reverse()
939
+ .reduce(
940
+ (total, digit, index) =>
941
+ total + Number(digit) * (index % 2 === 0 ? 3 : 1),
942
+ 0,
943
+ );
944
+ return String((10 - (sum % 10)) % 10);
945
+ };
946
+
947
+ const isValidGtin = (value, length) =>
948
+ typeof value === "string" &&
949
+ value.length === length &&
950
+ /^\d+$/u.test(value) &&
951
+ value.at(-1) === gtinCheckDigit(value.slice(0, -1));
952
+
468
953
  const pseudonym = (key, recipe, value) => {
469
954
  if (value === null) return null;
470
955
  const bytes = digest(key, recipe.namespace, value);
@@ -481,62 +966,119 @@ const pseudonym = (key, recipe, value) => {
481
966
  if (recipe.format === "integer") {
482
967
  return Number(bytes.readBigUInt64BE(0) % 9_007_199_254_740_991n) + 1;
483
968
  }
969
+ if (recipe.format === "hex") {
970
+ return bytes.toString("hex").slice(0, recipe.length);
971
+ }
972
+ if (recipe.format === "gtin") {
973
+ const allowedLengths = recipe.allowedLengths ?? [recipe.length];
974
+ const sourceLength = typeof value === "string" ? value.length : null;
975
+ if (
976
+ sourceLength === null ||
977
+ !allowedLengths.includes(sourceLength) ||
978
+ !isValidGtin(value, sourceLength)
979
+ ) {
980
+ if (recipe.length !== undefined) {
981
+ throw new Error(
982
+ `A gtin pseudonym source must be a valid normalized GTIN-${recipe.length} string.`,
983
+ );
984
+ }
985
+ throw new Error(
986
+ `A gtin pseudonym source must be a valid normalized GTIN string with an allowed length (${allowedLengths.join(", ")}).`,
987
+ );
988
+ }
989
+ const modulus = 10n ** BigInt(sourceLength - 1);
990
+ const payload = (BigInt(`0x${bytes.toString("hex")}`) % modulus)
991
+ .toString()
992
+ .padStart(sourceLength - 1, "0");
993
+ return `${payload}${gtinCheckDigit(payload)}`;
994
+ }
995
+ if (recipe.format === "url") {
996
+ if (
997
+ typeof value !== "string" ||
998
+ value.trim() !== value ||
999
+ /\s/u.test(value)
1000
+ ) {
1001
+ throw new Error("A url pseudonym source must be a valid HTTPS URL.");
1002
+ }
1003
+ let source;
1004
+ try {
1005
+ source = new URL(value);
1006
+ } catch {
1007
+ throw new Error("A url pseudonym source must be a valid HTTPS URL.");
1008
+ }
1009
+ if (source.protocol !== "https:") {
1010
+ throw new Error("A url pseudonym source must be a valid HTTPS URL.");
1011
+ }
1012
+ const output = `${recipe.origin}${URL_PATH_PREFIX}${bytes
1013
+ .toString("hex")
1014
+ .slice(0, URL_TOKEN_LENGTH)}`;
1015
+ if (output.length > recipe.maxLength) {
1016
+ throw new Error("A url pseudonym exceeded its reviewed maximum length.");
1017
+ }
1018
+ return output;
1019
+ }
484
1020
  const token = `rehearsal_${bytes.toString("hex")}`;
485
1021
  return token.slice(0, recipe.maxLength);
486
1022
  };
487
1023
 
488
- const assertJsonBounds = (value, recipe, path) => {
489
- let bytes = 0;
490
- const jsonStringBytes = (string) => {
491
- let size = 2;
492
- for (let index = 0; index < string.length; index += 1) {
493
- const code = string.charCodeAt(index);
494
- if (code === 34 || code === 92) size += 2;
495
- else if (code < 32) size += [8, 9, 10, 12, 13].includes(code) ? 2 : 6;
496
- else if (code >= 0xd800 && code <= 0xdbff) {
497
- const next = string.charCodeAt(index + 1);
498
- if (next >= 0xdc00 && next <= 0xdfff) {
499
- size += 4;
500
- index += 1;
501
- } else size += 6;
502
- } else if (code >= 0xdc00 && code <= 0xdfff) size += 6;
503
- else if (code <= 0x7f) size += 1;
504
- else if (code <= 0x7ff) size += 2;
505
- else size += 3;
506
- }
507
- return size;
508
- };
509
- const visit = (current, depth) => {
510
- if (depth > recipe.maximumDepth) {
511
- throw new Error(`${path} exceeds maximumDepth ${recipe.maximumDepth}.`);
512
- }
513
- if (typeof current === "string") bytes += jsonStringBytes(current);
514
- else if (current === null) bytes += 4;
515
- else if (typeof current === "number" && !Number.isFinite(current))
516
- throw new Error(`${path} contains an unsupported JSON value.`);
517
- else if (["number", "boolean"].includes(typeof current))
518
- bytes += String(current).length;
519
- else if (Array.isArray(current)) {
520
- if (
521
- recipe.kind === "json-array" &&
522
- current.length > recipe.maximumItems
523
- ) {
524
- throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
525
- }
526
- bytes += 2 + Math.max(0, current.length - 1);
527
- for (const item of current) visit(item, depth + 1);
528
- } else if (isObject(current)) {
529
- bytes += 2 + Math.max(0, Object.keys(current).length - 1);
530
- for (const [key, nested] of Object.entries(current)) {
531
- bytes += jsonStringBytes(key) + 1;
532
- visit(nested, depth + 1);
533
- }
534
- } else throw new Error(`${path} contains an unsupported JSON value.`);
535
- if (bytes > recipe.maximumBytes) {
536
- throw new Error(`${path} exceeds maximumBytes ${recipe.maximumBytes}.`);
537
- }
538
- };
539
- visit(value, 1);
1024
+ const remapPath = ({ key, mapping, bindingValue, value, label }) => {
1025
+ if (typeof value !== "string") {
1026
+ throw new Error(`${label} must be a Storage-style path string.`);
1027
+ }
1028
+ if (Buffer.byteLength(value) > mapping.maximumBytes) {
1029
+ throw new Error(`${label} exceeds its reviewed byte limit.`);
1030
+ }
1031
+ const segments = value.split("/");
1032
+ if (
1033
+ !value ||
1034
+ value.startsWith("/") ||
1035
+ value.endsWith("/") ||
1036
+ segments.length > mapping.maximumSegments ||
1037
+ segments.some((segment) => !segment || segment === "." || segment === "..")
1038
+ ) {
1039
+ throw new Error(`${label} is not a safe bounded Storage path.`);
1040
+ }
1041
+ if (
1042
+ typeof bindingValue !== "string" ||
1043
+ !bindingValue ||
1044
+ bindingValue.includes("/") ||
1045
+ bindingValue === "." ||
1046
+ bindingValue === ".."
1047
+ ) {
1048
+ throw new Error(`${label} has an invalid reviewed identity binding.`);
1049
+ }
1050
+ if (segments[0] !== bindingValue) {
1051
+ throw new Error(
1052
+ `${label} does not begin with its exact reviewed identity.`,
1053
+ );
1054
+ }
1055
+ const destination = [
1056
+ String(pseudonym(key, mapping, bindingValue)),
1057
+ ...segments.slice(1),
1058
+ ].join("/");
1059
+ if (Buffer.byteLength(destination) > mapping.maximumBytes) {
1060
+ throw new Error(`${label} destination exceeds its reviewed byte limit.`);
1061
+ }
1062
+ return destination;
1063
+ };
1064
+
1065
+ const substituteBinding = ({ key, recipe, bindingValue, value, label }) => {
1066
+ if (typeof bindingValue !== "string" || !UUID.test(bindingValue)) {
1067
+ throw new Error(`${label} has an invalid reviewed UUID binding.`);
1068
+ }
1069
+ if (value === null) return null;
1070
+ if (typeof value !== "string") {
1071
+ throw new Error(`${label} must be text or null for binding substitution.`);
1072
+ }
1073
+ if (Buffer.byteLength(value) > recipe.maximumBytes) {
1074
+ throw new Error(`${label} exceeds its reviewed byte limit.`);
1075
+ }
1076
+ const replacement = String(pseudonym(key, recipe, bindingValue));
1077
+ const destination = value.replaceAll(bindingValue, replacement);
1078
+ if (Buffer.byteLength(destination) > recipe.maximumBytes) {
1079
+ throw new Error(`${label} destination exceeds its reviewed byte limit.`);
1080
+ }
1081
+ return destination;
540
1082
  };
541
1083
 
542
1084
  const executeDeclaration = ({
@@ -545,6 +1087,9 @@ const executeDeclaration = ({
545
1087
  key,
546
1088
  path,
547
1089
  ownerApproved,
1090
+ pathMappings,
1091
+ bindingValues,
1092
+ resolveInput,
548
1093
  }) => {
549
1094
  if (declaration.action === "KEEP") return value;
550
1095
  if (declaration.action === "EXCLUDE") return undefined;
@@ -555,12 +1100,82 @@ const executeDeclaration = ({
555
1100
  }
556
1101
  if (declaration.recipe.kind === "date-shift") {
557
1102
  if (value === null) return null;
1103
+ if (
1104
+ declaration.recipe.representation === "epoch-milliseconds" &&
1105
+ (typeof value !== "number" || !Number.isSafeInteger(value))
1106
+ ) {
1107
+ throw new Error(`${path} must be a safe epoch-millisecond integer.`);
1108
+ }
558
1109
  const date = new Date(value);
559
1110
  if (!Number.isFinite(date.valueOf()))
560
1111
  throw new Error(`${path} is not a valid date.`);
561
- const direction = digest(key, path, value)[0] % 2 === 0 ? -1 : 1;
1112
+ const direction = declaration.recipe.group
1113
+ ? digest(key, "date-shift-group", declaration.recipe.group)[0] % 2 === 0
1114
+ ? -1
1115
+ : 1
1116
+ : digest(key, path, value)[0] % 2 === 0
1117
+ ? -1
1118
+ : 1;
562
1119
  date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
563
- return date.toISOString();
1120
+ if (!Number.isFinite(date.valueOf())) {
1121
+ throw new Error(`${path} shifts outside the supported date range.`);
1122
+ }
1123
+ return declaration.recipe.representation === "epoch-milliseconds"
1124
+ ? date.valueOf()
1125
+ : date.toISOString();
1126
+ }
1127
+ if (declaration.recipe.kind === "enum") {
1128
+ if (value === null && declaration.recipe.allowNull) return null;
1129
+ if (
1130
+ !declaration.recipe.values.some((candidate) =>
1131
+ Object.is(candidate, value),
1132
+ )
1133
+ ) {
1134
+ throw new Error(`${path} is outside its reviewed scalar domain.`);
1135
+ }
1136
+ return value;
1137
+ }
1138
+ if (declaration.recipe.kind === "validated-string") {
1139
+ if (value === null && declaration.recipe.allowNull) return null;
1140
+ if (
1141
+ typeof value !== "string" ||
1142
+ Buffer.byteLength(value) > declaration.recipe.maximumBytes ||
1143
+ !PORTABLE_CODE.test(value)
1144
+ ) {
1145
+ throw new Error(`${path} is not a valid reviewed portable code.`);
1146
+ }
1147
+ return value;
1148
+ }
1149
+ if (declaration.recipe.kind === "digest") {
1150
+ const inputs = declaration.recipe.inputs.map((name) => {
1151
+ const sanitized = resolveInput(name);
1152
+ if (sanitized === undefined) {
1153
+ throw new Error(`${path} digest input ${name} was not sanitized.`);
1154
+ }
1155
+ return { name, value: sanitized };
1156
+ });
1157
+ return digest(key, declaration.recipe.namespace, inputs)
1158
+ .toString("hex")
1159
+ .slice(0, declaration.recipe.length);
1160
+ }
1161
+ if (declaration.recipe.kind === "path-map") {
1162
+ const mapping = pathMappings[declaration.recipe.mapping];
1163
+ return remapPath({
1164
+ key,
1165
+ mapping,
1166
+ bindingValue: bindingValues.get(mapping.binding),
1167
+ value,
1168
+ label: path,
1169
+ });
1170
+ }
1171
+ if (declaration.recipe.kind === "binding-substitute") {
1172
+ return substituteBinding({
1173
+ key,
1174
+ recipe: declaration.recipe,
1175
+ bindingValue: bindingValues.get(declaration.recipe.binding),
1176
+ value,
1177
+ label: path,
1178
+ });
564
1179
  }
565
1180
  if (declaration.recipe.kind === "approved-owner") {
566
1181
  if (ownerApproved === undefined) {
@@ -574,47 +1189,32 @@ const executeDeclaration = ({
574
1189
  key,
575
1190
  path,
576
1191
  ownerApproved,
1192
+ pathMappings,
1193
+ bindingValues,
1194
+ resolveInput,
577
1195
  });
578
1196
  }
579
- if (value === null && declaration.recipe.allowNull) return null;
580
- assertJsonBounds(value, declaration.recipe, path);
581
- if (declaration.recipe.kind === "json-array") {
582
- if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
583
- return value.flatMap((item, index) => {
584
- const transformed = executeDeclaration({
585
- declaration: declaration.recipe.items,
586
- value: item,
587
- key,
588
- path: `${path}[${index}]`,
589
- ownerApproved,
590
- });
591
- return transformed === undefined ? [] : [transformed];
592
- });
593
- }
594
- if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
595
- const actual = Object.keys(value).sort();
596
- const expected = Object.keys(declaration.recipe.fields);
597
- if (actual.some((field) => !expected.includes(field))) {
598
- throw new Error(`${path} contains an unclassified JSON key.`);
1197
+ if (!STRUCTURED_JSON_KINDS.has(declaration.recipe.kind)) {
1198
+ throw new Error(`${path} uses an unsupported privacy recipe.`);
599
1199
  }
600
- const missing = expected.filter(
601
- (field) =>
602
- declaration.recipe.fields[field].required && !Object.hasOwn(value, field),
603
- );
604
- if (missing.length) throw new Error(`${path} is missing required JSON keys.`);
605
- return Object.fromEntries(
606
- Object.entries(declaration.recipe.fields).flatMap(([field, nested]) => {
607
- if (!Object.hasOwn(value, field)) return [];
608
- const transformed = executeDeclaration({
609
- declaration: nested,
610
- value: value[field],
1200
+ return executeStructuredJsonDeclaration({
1201
+ declaration,
1202
+ value,
1203
+ key,
1204
+ path,
1205
+ pseudonymize: pseudonym,
1206
+ execute: (nestedDeclaration, nestedValue, nestedPath) =>
1207
+ executeDeclaration({
1208
+ declaration: nestedDeclaration,
1209
+ value: nestedValue,
611
1210
  key,
612
- path: `${path}.${field}`,
1211
+ path: nestedPath,
613
1212
  ownerApproved,
614
- });
615
- return transformed === undefined ? [] : [[field, transformed]];
616
- }),
617
- );
1213
+ pathMappings,
1214
+ bindingValues,
1215
+ resolveInput,
1216
+ }),
1217
+ });
618
1218
  };
619
1219
 
620
1220
  export const createPrivacyEngine = ({
@@ -652,6 +1252,19 @@ export const createPrivacyEngine = ({
652
1252
  );
653
1253
  return Object.freeze({
654
1254
  keyFingerprint: createHash("sha256").update(secret).digest("hex"),
1255
+ remapPath({ mapping: mappingName, value }) {
1256
+ const mapping = normalized.pathMappings[mappingName];
1257
+ if (!mapping) {
1258
+ throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
1259
+ }
1260
+ return remapPath({
1261
+ key: secret,
1262
+ mapping,
1263
+ bindingValue: bindingValues.get(mapping.binding),
1264
+ value,
1265
+ label: `Privacy path mapping ${mappingName}`,
1266
+ });
1267
+ },
655
1268
  sanitize(record) {
656
1269
  if (
657
1270
  !isObject(record) ||
@@ -677,22 +1290,67 @@ export const createPrivacyEngine = ({
677
1290
  }
678
1291
  let ownerApproved;
679
1292
  if (table.ownerBinding) {
680
- const owner = record.row[table.ownerBinding.column];
681
- if (!["string", "number"].includes(typeof owner) || owner === "") {
682
- throw new Error(`${relation} has an invalid owner binding value.`);
1293
+ const approvedValue = bindingValues.get(table.ownerBinding.binding);
1294
+ const owners = table.ownerBinding.columns.map(
1295
+ (column) => record.row[column],
1296
+ );
1297
+ for (const owner of owners) {
1298
+ if (owner === null) {
1299
+ if (table.ownerBinding.nullBehavior !== "otherwise") {
1300
+ throw new Error(
1301
+ `${relation} has an invalid owner binding value.`,
1302
+ );
1303
+ }
1304
+ continue;
1305
+ }
1306
+ if (
1307
+ !["string", "number"].includes(typeof owner) ||
1308
+ owner === "" ||
1309
+ (typeof owner === "number" && !Number.isFinite(owner))
1310
+ ) {
1311
+ throw new Error(`${relation} has an invalid owner binding value.`);
1312
+ }
683
1313
  }
684
- ownerApproved =
685
- String(owner) === bindingValues.get(table.ownerBinding.binding);
1314
+ ownerApproved = owners.some(
1315
+ (owner) => owner !== null && String(owner) === approvedValue,
1316
+ );
686
1317
  }
687
- const row = Object.fromEntries(
688
- table.columns.flatMap((column) => {
1318
+ const sanitizedColumns = new Map();
1319
+ const resolvingColumns = new Set();
1320
+ const sanitizeColumn = (columnName) => {
1321
+ if (sanitizedColumns.has(columnName)) {
1322
+ return sanitizedColumns.get(columnName);
1323
+ }
1324
+ if (resolvingColumns.has(columnName)) {
1325
+ throw new Error(`${relation} contains a cyclic digest dependency.`);
1326
+ }
1327
+ const column = table.columns.find((entry) => entry.name === columnName);
1328
+ if (!column) {
1329
+ throw new Error(
1330
+ `${relation} digest references unknown column ${columnName}.`,
1331
+ );
1332
+ }
1333
+ resolvingColumns.add(columnName);
1334
+ try {
689
1335
  const transformed = executeDeclaration({
690
1336
  declaration: column,
691
1337
  value: record.row[column.name],
692
1338
  key: secret,
693
1339
  path: `${relation}.${column.name}`,
694
1340
  ownerApproved,
1341
+ pathMappings: normalized.pathMappings,
1342
+ bindingValues,
1343
+ resolveInput: sanitizeColumn,
695
1344
  });
1345
+ sanitizedColumns.set(columnName, transformed);
1346
+ return transformed;
1347
+ } finally {
1348
+ resolvingColumns.delete(columnName);
1349
+ }
1350
+ };
1351
+ const row = Object.fromEntries(
1352
+ table.columns.flatMap((column) => {
1353
+ const transformed = sanitizeColumn(column.name);
696
1354
  return transformed === undefined ? [] : [[column.name, transformed]];
697
1355
  }),
698
1356
  );