@rehearsal-db/core 0.1.0-beta.13 → 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.
@@ -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,20 +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",
25
48
  "path-map",
26
49
  "json-object",
27
50
  "json-array",
51
+ "json-union",
52
+ "json-dictionary",
28
53
  "approved-owner",
54
+ "binding-substitute",
29
55
  ]);
30
56
  const KEY_BYTES = 32;
31
- const DEFAULT_JSON_MAXIMUM_BYTES = 65_536;
32
- 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"]);
33
62
  const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
34
63
  const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
35
64
  const MAXIMUM_POLICY_DEPTH = 16;
65
+ const MAXIMUM_ENUM_VALUES = 128;
66
+ const MAXIMUM_ENUM_STRING_BYTES = 256;
36
67
 
37
68
  const isObject = (value) =>
38
69
  value !== null &&
@@ -67,6 +98,51 @@ const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
67
98
  return value;
68
99
  };
69
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
+
70
146
  const validateNestedDeclaration = (
71
147
  declaration,
72
148
  label,
@@ -108,7 +184,18 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
108
184
  throw new Error(`${label}.recipe is required for ${action}.`);
109
185
  }
110
186
  if (action === "PSEUDONYMIZE") {
111
- 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
+ );
112
199
  if (!PSEUDONYM_FORMATS.has(recipe.format)) {
113
200
  throw new Error(`${label}.recipe.format is unsupported.`);
114
201
  }
@@ -122,9 +209,74 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
122
209
  `${label}.recipe.maxLength`,
123
210
  1_024,
124
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
+ }
125
229
  } else if (recipe.maxLength !== undefined) {
126
230
  throw new Error(
127
- `${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.`,
128
280
  );
129
281
  }
130
282
  return Object.freeze(normalized);
@@ -146,9 +298,18 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
146
298
  "kind",
147
299
  "days",
148
300
  "group",
301
+ "representation",
302
+ "format",
303
+ "length",
304
+ "namespace",
305
+ "inputs",
149
306
  "mapping",
307
+ "binding",
150
308
  "fields",
151
309
  "items",
310
+ "variants",
311
+ "keys",
312
+ "values",
152
313
  "approved",
153
314
  "otherwise",
154
315
  "allowNull",
@@ -161,11 +322,120 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
161
322
  if (!DERIVATION_KINDS.has(recipe.kind)) {
162
323
  throw new Error(`${label}.recipe.kind is unsupported.`);
163
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
+ }
164
434
  if (recipe.kind === "date-shift") {
165
435
  const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
166
436
  if (
167
437
  Object.keys(recipe).some(
168
- (key) => !["kind", "days", "group"].includes(key),
438
+ (key) => !["kind", "days", "group", "representation"].includes(key),
169
439
  )
170
440
  ) {
171
441
  throw new Error(
@@ -175,9 +445,19 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
175
445
  if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
176
446
  throw new Error(`${label}.recipe.group is invalid.`);
177
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
+ }
178
457
  return Object.freeze({
179
458
  kind: "date-shift",
180
459
  days,
460
+ representation,
181
461
  ...(recipe.group === undefined ? {} : { group: recipe.group }),
182
462
  });
183
463
  }
@@ -190,6 +470,37 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
190
470
  }
191
471
  return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
192
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
+ });
503
+ }
193
504
  if (recipe.kind === "approved-owner") {
194
505
  if (
195
506
  Object.keys(recipe).some(
@@ -214,90 +525,14 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
214
525
  ),
215
526
  });
216
527
  }
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),
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),
301
536
  });
302
537
  };
303
538
 
@@ -491,37 +726,90 @@ export const validateExecutablePrivacyPolicy = (policy) => {
491
726
  }
492
727
  knownKeys(
493
728
  table.ownerBinding,
494
- ["binding", "column"],
729
+ ["binding", "column", "columns", "match", "nullBehavior"],
495
730
  `${label}.ownerBinding`,
496
731
  );
497
732
  const binding = nonEmpty(
498
733
  table.ownerBinding.binding,
499
734
  `${label}.ownerBinding.binding`,
500
735
  );
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.`);
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
+ );
507
742
  }
508
- if (!columnNames.has(column)) {
509
- 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
+ }
510
776
  }
511
- const ownerColumn = columns.find((entry) => entry.name === column);
512
- if (ownerColumn.action !== "PSEUDONYMIZE") {
777
+ if (
778
+ table.ownerBinding.nullBehavior !== undefined &&
779
+ table.ownerBinding.nullBehavior !== "otherwise"
780
+ ) {
513
781
  throw new Error(
514
- `${label}.ownerBinding column must be explicitly pseudonymized.`,
782
+ `${label}.ownerBinding.nullBehavior must be otherwise when declared.`,
515
783
  );
516
784
  }
517
- 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
+ });
518
809
  }
519
810
  const usesApprovedOwner = (declaration) =>
520
811
  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));
812
+ structuredJsonDeclarations(declaration.recipe).some(usesApprovedOwner);
525
813
  if (columns.some(usesApprovedOwner) && !ownerBinding) {
526
814
  throw new Error(`${label} uses approved-owner without ownerBinding.`);
527
815
  }
@@ -530,12 +818,10 @@ export const validateExecutablePrivacyPolicy = (policy) => {
530
818
  if (declaration.recipe?.kind === "approved-owner") {
531
819
  visitRecipes(declaration.recipe.approved, visit);
532
820
  visitRecipes(declaration.recipe.otherwise, visit);
533
- } else if (declaration.recipe?.kind === "json-object") {
534
- Object.values(declaration.recipe.fields).forEach((nested) =>
821
+ } else {
822
+ structuredJsonDeclarations(declaration.recipe).forEach((nested) =>
535
823
  visitRecipes(nested, visit),
536
824
  );
537
- } else if (declaration.recipe?.kind === "json-array") {
538
- visitRecipes(declaration.recipe.items, visit);
539
825
  }
540
826
  };
541
827
  for (const column of columns) {
@@ -548,8 +834,61 @@ export const validateExecutablePrivacyPolicy = (policy) => {
548
834
  `${label} references unknown path mapping ${recipe.mapping}.`,
549
835
  );
550
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
+ }
551
845
  });
552
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));
553
892
  return Object.freeze({
554
893
  schema,
555
894
  name,
@@ -573,10 +912,8 @@ export const validateExecutablePrivacyPolicy = (policy) => {
573
912
  if (recipe?.kind === "approved-owner") {
574
913
  visitDateShiftGroups(recipe.approved);
575
914
  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);
915
+ } else {
916
+ structuredJsonDeclarations(recipe).forEach(visitDateShiftGroups);
580
917
  }
581
918
  };
582
919
  tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
@@ -596,6 +933,23 @@ const digest = (key, namespace, value) =>
596
933
  .update(JSON.stringify(value))
597
934
  .digest();
598
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
+
599
953
  const pseudonym = (key, recipe, value) => {
600
954
  if (value === null) return null;
601
955
  const bytes = digest(key, recipe.namespace, value);
@@ -612,6 +966,57 @@ const pseudonym = (key, recipe, value) => {
612
966
  if (recipe.format === "integer") {
613
967
  return Number(bytes.readBigUInt64BE(0) % 9_007_199_254_740_991n) + 1;
614
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
+ }
615
1020
  const token = `rehearsal_${bytes.toString("hex")}`;
616
1021
  return token.slice(0, recipe.maxLength);
617
1022
  };
@@ -657,58 +1062,23 @@ const remapPath = ({ key, mapping, bindingValue, value, label }) => {
657
1062
  return destination;
658
1063
  };
659
1064
 
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);
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;
712
1082
  };
713
1083
 
714
1084
  const executeDeclaration = ({
@@ -719,6 +1089,7 @@ const executeDeclaration = ({
719
1089
  ownerApproved,
720
1090
  pathMappings,
721
1091
  bindingValues,
1092
+ resolveInput,
722
1093
  }) => {
723
1094
  if (declaration.action === "KEEP") return value;
724
1095
  if (declaration.action === "EXCLUDE") return undefined;
@@ -729,6 +1100,12 @@ const executeDeclaration = ({
729
1100
  }
730
1101
  if (declaration.recipe.kind === "date-shift") {
731
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
+ }
732
1109
  const date = new Date(value);
733
1110
  if (!Number.isFinite(date.valueOf()))
734
1111
  throw new Error(`${path} is not a valid date.`);
@@ -740,7 +1117,46 @@ const executeDeclaration = ({
740
1117
  ? -1
741
1118
  : 1;
742
1119
  date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
743
- 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);
744
1160
  }
745
1161
  if (declaration.recipe.kind === "path-map") {
746
1162
  const mapping = pathMappings[declaration.recipe.mapping];
@@ -752,6 +1168,15 @@ const executeDeclaration = ({
752
1168
  label: path,
753
1169
  });
754
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
+ });
1179
+ }
755
1180
  if (declaration.recipe.kind === "approved-owner") {
756
1181
  if (ownerApproved === undefined) {
757
1182
  throw new Error(`${path} has no reviewed owner condition.`);
@@ -766,51 +1191,30 @@ const executeDeclaration = ({
766
1191
  ownerApproved,
767
1192
  pathMappings,
768
1193
  bindingValues,
1194
+ resolveInput,
769
1195
  });
770
1196
  }
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.`);
1197
+ if (!STRUCTURED_JSON_KINDS.has(declaration.recipe.kind)) {
1198
+ throw new Error(`${path} uses an unsupported privacy recipe.`);
793
1199
  }
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],
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,
805
1210
  key,
806
- path: `${path}.${field}`,
1211
+ path: nestedPath,
807
1212
  ownerApproved,
808
1213
  pathMappings,
809
1214
  bindingValues,
810
- });
811
- return transformed === undefined ? [] : [[field, transformed]];
812
- }),
813
- );
1215
+ resolveInput,
1216
+ }),
1217
+ });
814
1218
  };
815
1219
 
816
1220
  export const createPrivacyEngine = ({
@@ -886,15 +1290,48 @@ export const createPrivacyEngine = ({
886
1290
  }
887
1291
  let ownerApproved;
888
1292
  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.`);
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
+ }
892
1313
  }
893
- ownerApproved =
894
- String(owner) === bindingValues.get(table.ownerBinding.binding);
1314
+ ownerApproved = owners.some(
1315
+ (owner) => owner !== null && String(owner) === approvedValue,
1316
+ );
895
1317
  }
896
- const row = Object.fromEntries(
897
- 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 {
898
1335
  const transformed = executeDeclaration({
899
1336
  declaration: column,
900
1337
  value: record.row[column.name],
@@ -903,7 +1340,17 @@ export const createPrivacyEngine = ({
903
1340
  ownerApproved,
904
1341
  pathMappings: normalized.pathMappings,
905
1342
  bindingValues,
1343
+ resolveInput: sanitizeColumn,
906
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);
907
1354
  return transformed === undefined ? [] : [[column.name, transformed]];
908
1355
  }),
909
1356
  );