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

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 (113) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/dist/package.json +10 -9
  3. package/dist/src/application/interactive_task.d.mts +30 -0
  4. package/dist/src/application/interactive_task.mjs +83 -0
  5. package/dist/src/application/session.d.mts +1 -0
  6. package/dist/src/baseline/privacy_contract.d.mts +147 -0
  7. package/dist/src/baseline/privacy_contract.mjs +2 -0
  8. package/dist/src/baseline/privacy_dependency_graph.d.mts +7 -0
  9. package/dist/src/baseline/privacy_dependency_graph.mjs +110 -0
  10. package/dist/src/baseline/privacy_engine.d.mts +5 -162
  11. package/dist/src/baseline/privacy_engine.mjs +4 -1162
  12. package/dist/src/baseline/privacy_key.d.mts +7 -0
  13. package/dist/src/baseline/privacy_key.mjs +42 -0
  14. package/dist/src/baseline/privacy_pipeline.d.mts +7 -0
  15. package/dist/src/baseline/privacy_pipeline.mjs +127 -0
  16. package/dist/src/baseline/privacy_policy.d.mts +6 -0
  17. package/dist/src/baseline/privacy_policy.mjs +237 -0
  18. package/dist/src/baseline/privacy_recipe_validation.d.mts +5 -0
  19. package/dist/src/baseline/privacy_recipe_validation.mjs +289 -0
  20. package/dist/src/baseline/privacy_rule_registry.d.mts +18 -0
  21. package/dist/src/baseline/privacy_rule_registry.mjs +143 -0
  22. package/dist/src/baseline/privacy_transforms.d.mts +18 -0
  23. package/dist/src/baseline/privacy_transforms.mjs +142 -0
  24. package/dist/src/baseline/privacy_validation_shared.d.mts +31 -0
  25. package/dist/src/baseline/privacy_validation_shared.mjs +127 -0
  26. package/dist/src/cli/application_command_handlers.d.mts +8 -0
  27. package/dist/src/cli/application_command_handlers.mjs +52 -0
  28. package/dist/src/cli/arguments.d.mts +0 -5
  29. package/dist/src/cli/arguments.mjs +2 -7
  30. package/dist/src/cli/basic_command_handlers.d.mts +4 -0
  31. package/dist/src/cli/basic_command_handlers.mjs +57 -0
  32. package/dist/src/cli/cli_commands.d.mts +14 -0
  33. package/dist/src/cli/cli_commands.mjs +34 -0
  34. package/dist/src/cli/command_contract.d.mts +57 -0
  35. package/dist/src/cli/command_contract.mjs +39 -0
  36. package/dist/src/cli/command_output.d.mts +12 -0
  37. package/dist/src/cli/command_output.mjs +20 -0
  38. package/dist/src/cli/command_registry.d.mts +7 -0
  39. package/dist/src/cli/command_registry.mjs +14 -0
  40. package/dist/src/cli/guided.d.mts +3 -1
  41. package/dist/src/cli/guided.mjs +21 -7
  42. package/dist/src/cli/identity_command_handlers.d.mts +12 -0
  43. package/dist/src/cli/identity_command_handlers.mjs +239 -0
  44. package/dist/src/cli/identity_renderer.d.mts +8 -0
  45. package/dist/src/cli/identity_renderer.mjs +33 -0
  46. package/dist/src/cli/init_command.d.mts +1 -1
  47. package/dist/src/cli/inspection_command_handlers.d.mts +3 -0
  48. package/dist/src/cli/inspection_command_handlers.mjs +79 -0
  49. package/dist/src/cli/project_command_handlers.d.mts +11 -0
  50. package/dist/src/cli/project_command_handlers.mjs +92 -0
  51. package/dist/src/cli/project_commands.d.mts +131 -5
  52. package/dist/src/cli/project_commands.mjs +17 -8
  53. package/dist/src/cli/rehearsal.d.mts +2 -2
  54. package/dist/src/cli/rehearsal.mjs +51 -567
  55. package/dist/src/cli/runtime_action_handlers.d.mts +12 -0
  56. package/dist/src/cli/runtime_action_handlers.mjs +201 -0
  57. package/dist/src/cli/runtime_commands.d.mts +32 -3
  58. package/dist/src/cli/runtime_commands.mjs +33 -13
  59. package/dist/src/cli/source_command_handlers.d.mts +8 -0
  60. package/dist/src/cli/source_command_handlers.mjs +62 -0
  61. package/dist/src/cli/source_commands.d.mts +5 -1
  62. package/dist/src/cli/source_commands.mjs +3 -1
  63. package/dist/src/identity/claim.d.mts +5 -144
  64. package/dist/src/identity/claim.mjs +4 -947
  65. package/dist/src/identity/claim_contract.d.mts +135 -0
  66. package/dist/src/identity/claim_contract.mjs +6 -0
  67. package/dist/src/identity/claim_executor.d.mts +10 -0
  68. package/dist/src/identity/claim_executor.mjs +358 -0
  69. package/dist/src/identity/claim_identity_lookup.d.mts +23 -0
  70. package/dist/src/identity/claim_identity_lookup.mjs +134 -0
  71. package/dist/src/identity/claim_plan.d.mts +8 -0
  72. package/dist/src/identity/claim_plan.mjs +61 -0
  73. package/dist/src/identity/claim_policy.d.mts +3 -0
  74. package/dist/src/identity/claim_policy.mjs +377 -0
  75. package/dist/src/identity/claim_sql.d.mts +10 -0
  76. package/dist/src/identity/claim_sql.mjs +46 -0
  77. package/dist/src/identity/claim_verification.d.mts +13 -0
  78. package/dist/src/identity/claim_verification.mjs +35 -0
  79. package/dist/src/identity/connect.d.mts +30 -0
  80. package/dist/src/identity/connect.mjs +111 -0
  81. package/dist/src/index.d.mts +1 -1
  82. package/dist/src/project/configuration.d.mts +11 -179
  83. package/dist/src/project/configuration.mjs +7 -956
  84. package/dist/src/project/configuration_contract.d.mts +125 -0
  85. package/dist/src/project/configuration_contract.mjs +27 -0
  86. package/dist/src/project/configuration_discovery.d.mts +7 -0
  87. package/dist/src/project/configuration_discovery.mjs +37 -0
  88. package/dist/src/project/configuration_normalization.d.mts +107 -0
  89. package/dist/src/project/configuration_normalization.mjs +302 -0
  90. package/dist/src/project/configuration_rendering.d.mts +14 -0
  91. package/dist/src/project/configuration_rendering.mjs +226 -0
  92. package/dist/src/project/configuration_resolution.d.mts +125 -0
  93. package/dist/src/project/configuration_resolution.mjs +81 -0
  94. package/dist/src/project/configuration_schema.d.mts +22 -0
  95. package/dist/src/project/configuration_schema.mjs +160 -0
  96. package/dist/src/project/configuration_validation.d.mts +22 -0
  97. package/dist/src/project/configuration_validation.mjs +136 -0
  98. package/dist/src/project/project_detection.d.mts +14 -0
  99. package/dist/src/project/project_detection.mjs +47 -0
  100. package/dist/src/runtime/lifecycle_engine.d.mts +63 -0
  101. package/dist/src/runtime/lifecycle_engine.mjs +114 -0
  102. package/dist/src/runtime/plan.mjs +2 -1
  103. package/dist/src/targets/postgresql.mjs +142 -163
  104. package/dist/src/targets/supabase.mjs +142 -174
  105. package/docs/architecture.md +120 -3
  106. package/docs/commands.md +18 -15
  107. package/docs/configuration.md +1 -0
  108. package/docs/releasing.md +8 -3
  109. package/docs/runtime-policies.md +12 -3
  110. package/docs/troubleshooting.md +17 -0
  111. package/docs/typescript.md +57 -10
  112. package/package.json +10 -9
  113. package/types/configuration.d.mts +5 -164
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Validate and normalize reviewed privacy recipes for policy version 2.
3
+ */
4
+ import { validateStructuredJsonRecipe } from "./privacy_structured_json.mjs";
5
+ import { ACTIONS, BINDING_NAME, DATE_REPRESENTATIONS, DERIVATION_KINDS, GTIN_LENGTHS, HEX_LENGTHS, MAXIMUM_ENUM_VALUES, MAXIMUM_POLICY_DEPTH, MAXIMUM_TEXT_SUBSTITUTION_BYTES, MAXIMUM_URL_LENGTH, PSEUDONYM_FORMATS, URL_PATH_PREFIX, URL_TOKEN_LENGTH, assertKnownKeys as knownKeys, isPrivacyObject as isObject, validateHttpsLoopbackOrigin as httpsLoopbackOrigin, validateIdentifier as identifier, validateNonEmpty as nonEmpty, validatePositiveInteger as positiveInteger, validateReviewedScalar as reviewedScalar, } from "./privacy_validation_shared.mjs";
6
+ const validateNestedDeclaration = (declaration, label, { allowRequired = false, depth = 0, } = {}) => {
7
+ if (!isObject(declaration))
8
+ throw new Error(`${label} must be an object.`);
9
+ knownKeys(declaration, allowRequired ? ["action", "recipe", "required"] : ["action", "recipe"], label);
10
+ if (allowRequired &&
11
+ declaration.required !== undefined &&
12
+ typeof declaration.required !== "boolean") {
13
+ throw new Error(`${label}.required must be true or false.`);
14
+ }
15
+ const action = String(declaration.action ?? "").toUpperCase();
16
+ if (!ACTIONS.has(action))
17
+ throw new Error(`${label}.action is unsupported.`);
18
+ return Object.freeze({
19
+ action: action,
20
+ recipe: validatePrivacyRecipe(action, declaration.recipe, label, depth + 1),
21
+ ...(allowRequired ? { required: declaration.required !== false } : {}),
22
+ });
23
+ };
24
+ export const validatePrivacyRecipe = (action, recipe, label, depth = 0) => {
25
+ if (depth > MAXIMUM_POLICY_DEPTH) {
26
+ throw new Error(`${label}.recipe exceeds the supported nesting depth.`);
27
+ }
28
+ if (action === "KEEP" || action === "EXCLUDE") {
29
+ if (recipe !== undefined && recipe !== null) {
30
+ throw new Error(`${label}.recipe is not allowed for ${action}.`);
31
+ }
32
+ return null;
33
+ }
34
+ if (!isObject(recipe)) {
35
+ throw new Error(`${label}.recipe is required for ${action}.`);
36
+ }
37
+ if (action === "PSEUDONYMIZE") {
38
+ knownKeys(recipe, [
39
+ "format",
40
+ "namespace",
41
+ "maxLength",
42
+ "length",
43
+ "allowedLengths",
44
+ "origin",
45
+ ], `${label}.recipe`);
46
+ if (typeof recipe.format !== "string" ||
47
+ !PSEUDONYM_FORMATS.has(recipe.format)) {
48
+ throw new Error(`${label}.recipe.format is unsupported.`);
49
+ }
50
+ const normalized = {
51
+ format: recipe.format,
52
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
53
+ };
54
+ if (recipe.format === "text") {
55
+ normalized.maxLength = positiveInteger(recipe.maxLength, `${label}.recipe.maxLength`, 1_024);
56
+ }
57
+ else if (recipe.format === "url") {
58
+ normalized.origin = httpsLoopbackOrigin(recipe.origin, `${label}.recipe.origin`);
59
+ normalized.maxLength = positiveInteger(recipe.maxLength, `${label}.recipe.maxLength`, MAXIMUM_URL_LENGTH);
60
+ const outputLength = normalized.origin.length + URL_PATH_PREFIX.length + URL_TOKEN_LENGTH;
61
+ if (normalized.maxLength < outputLength) {
62
+ throw new Error(`${label}.recipe.maxLength must allow the complete pseudonymous URL (${outputLength} characters).`);
63
+ }
64
+ }
65
+ else if (recipe.maxLength !== undefined) {
66
+ throw new Error(`${label}.recipe.maxLength is supported only for text and url pseudonyms.`);
67
+ }
68
+ if (recipe.format === "hex") {
69
+ if (typeof recipe.length !== "number" ||
70
+ !HEX_LENGTHS.has(recipe.length)) {
71
+ throw new Error(`${label}.recipe.length must be 32 or 64 for hex.`);
72
+ }
73
+ normalized.length = recipe.length;
74
+ }
75
+ else if (recipe.format === "gtin") {
76
+ const hasLength = Object.hasOwn(recipe, "length");
77
+ const hasAllowedLengths = Object.hasOwn(recipe, "allowedLengths");
78
+ if (hasLength === hasAllowedLengths) {
79
+ throw new Error(`${label}.recipe must declare exactly one of length or allowedLengths for gtin.`);
80
+ }
81
+ if (hasLength) {
82
+ if (typeof recipe.length !== "number" ||
83
+ !GTIN_LENGTHS.has(recipe.length)) {
84
+ throw new Error(`${label}.recipe.length must be 8, 12, 13, or 14 for gtin.`);
85
+ }
86
+ normalized.length = recipe.length;
87
+ }
88
+ else {
89
+ if (!Array.isArray(recipe.allowedLengths) ||
90
+ recipe.allowedLengths.length === 0) {
91
+ throw new Error(`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`);
92
+ }
93
+ const allowedLengths = recipe.allowedLengths.map((length) => {
94
+ if (typeof length !== "number" || !GTIN_LENGTHS.has(length)) {
95
+ throw new Error(`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`);
96
+ }
97
+ return length;
98
+ });
99
+ if (new Set(allowedLengths).size !== allowedLengths.length) {
100
+ throw new Error(`${label}.recipe.allowedLengths must not contain duplicates.`);
101
+ }
102
+ normalized.allowedLengths = Object.freeze(allowedLengths);
103
+ }
104
+ }
105
+ else if (recipe.length !== undefined ||
106
+ recipe.allowedLengths !== undefined ||
107
+ (recipe.origin !== undefined && recipe.format !== "url")) {
108
+ throw new Error(`${label}.recipe shape controls do not match its pseudonym format.`);
109
+ }
110
+ return Object.freeze(normalized);
111
+ }
112
+ if (action === "REPLACE") {
113
+ knownKeys(recipe, ["kind", "value"], `${label}.recipe`);
114
+ if (recipe.kind !== "constant" || !Object.hasOwn(recipe, "value")) {
115
+ throw new Error(`${label}.recipe must be an explicit constant.`);
116
+ }
117
+ if (typeof recipe.value === "function" || recipe.value === undefined) {
118
+ throw new Error(`${label}.recipe.value must be JSON-compatible.`);
119
+ }
120
+ JSON.stringify(recipe.value);
121
+ return Object.freeze({ kind: "constant", value: recipe.value });
122
+ }
123
+ knownKeys(recipe, [
124
+ "kind",
125
+ "days",
126
+ "group",
127
+ "representation",
128
+ "format",
129
+ "length",
130
+ "namespace",
131
+ "inputs",
132
+ "mapping",
133
+ "binding",
134
+ "fields",
135
+ "items",
136
+ "variants",
137
+ "keys",
138
+ "values",
139
+ "approved",
140
+ "otherwise",
141
+ "allowNull",
142
+ "maximumBytes",
143
+ "maximumDepth",
144
+ "maximumItems",
145
+ ], `${label}.recipe`);
146
+ if (typeof recipe.kind !== "string" || !DERIVATION_KINDS.has(recipe.kind)) {
147
+ throw new Error(`${label}.recipe.kind is unsupported.`);
148
+ }
149
+ if (recipe.kind === "enum") {
150
+ if (Object.keys(recipe).some((key) => !["kind", "values", "allowNull"].includes(key))) {
151
+ throw new Error(`${label}.recipe contains fields not used by enum.`);
152
+ }
153
+ if (!Array.isArray(recipe.values) ||
154
+ recipe.values.length === 0 ||
155
+ recipe.values.length > MAXIMUM_ENUM_VALUES) {
156
+ throw new Error(`${label}.recipe.values must contain 1 through ${MAXIMUM_ENUM_VALUES} reviewed scalars.`);
157
+ }
158
+ if (recipe.allowNull !== undefined &&
159
+ typeof recipe.allowNull !== "boolean") {
160
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
161
+ }
162
+ const values = Array.from(recipe.values, (value, index) => reviewedScalar(value, `${label}.recipe.values[${index}]`));
163
+ const identities = values.map((value) => JSON.stringify([typeof value, value]));
164
+ if (new Set(identities).size !== identities.length) {
165
+ throw new Error(`${label}.recipe.values must not contain duplicates.`);
166
+ }
167
+ return Object.freeze({
168
+ kind: "enum",
169
+ allowNull: recipe.allowNull === true,
170
+ values: Object.freeze(values),
171
+ });
172
+ }
173
+ if (recipe.kind === "validated-string") {
174
+ if (Object.keys(recipe).some((key) => !["kind", "format", "maximumBytes", "allowNull"].includes(key))) {
175
+ throw new Error(`${label}.recipe contains fields not used by validated-string.`);
176
+ }
177
+ if (recipe.format !== "portable-code") {
178
+ throw new Error(`${label}.recipe.format must be portable-code for validated-string.`);
179
+ }
180
+ const maximumBytes = positiveInteger(recipe.maximumBytes, `${label}.recipe.maximumBytes`, 1_024);
181
+ if (recipe.allowNull !== undefined &&
182
+ typeof recipe.allowNull !== "boolean") {
183
+ throw new Error(`${label}.recipe.allowNull must be true or false.`);
184
+ }
185
+ return Object.freeze({
186
+ kind: "validated-string",
187
+ format: "portable-code",
188
+ maximumBytes,
189
+ allowNull: recipe.allowNull === true,
190
+ });
191
+ }
192
+ if (recipe.kind === "digest") {
193
+ if (Object.keys(recipe).some((key) => !["kind", "format", "length", "namespace", "inputs"].includes(key))) {
194
+ throw new Error(`${label}.recipe contains fields not used by digest.`);
195
+ }
196
+ if (recipe.format !== "hex") {
197
+ throw new Error(`${label}.recipe.format must be hex for digest.`);
198
+ }
199
+ if (typeof recipe.length !== "number" || !HEX_LENGTHS.has(recipe.length)) {
200
+ throw new Error(`${label}.recipe.length must be 32 or 64 for digest.`);
201
+ }
202
+ if (!Array.isArray(recipe.inputs) ||
203
+ recipe.inputs.length === 0 ||
204
+ recipe.inputs.length > 32) {
205
+ throw new Error(`${label}.recipe.inputs must contain 1 through 32 columns.`);
206
+ }
207
+ const inputs = recipe.inputs.map((input, index) => identifier(input, `${label}.recipe.inputs[${index}]`));
208
+ if (new Set(inputs).size !== inputs.length) {
209
+ throw new Error(`${label}.recipe.inputs must not contain duplicates.`);
210
+ }
211
+ return Object.freeze({
212
+ kind: "digest",
213
+ format: "hex",
214
+ length: recipe.length,
215
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
216
+ inputs: Object.freeze(inputs),
217
+ });
218
+ }
219
+ if (recipe.kind === "date-shift") {
220
+ const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
221
+ if (Object.keys(recipe).some((key) => !["kind", "days", "group", "representation"].includes(key))) {
222
+ throw new Error(`${label}.recipe contains fields not used by date-shift.`);
223
+ }
224
+ if (recipe.group !== undefined &&
225
+ (typeof recipe.group !== "string" || !BINDING_NAME.test(recipe.group))) {
226
+ throw new Error(`${label}.recipe.group is invalid.`);
227
+ }
228
+ const representation = recipe.representation === undefined
229
+ ? "iso-string"
230
+ : recipe.representation;
231
+ if (typeof representation !== "string" ||
232
+ !DATE_REPRESENTATIONS.has(representation)) {
233
+ throw new Error(`${label}.recipe.representation must be iso-string or epoch-milliseconds.`);
234
+ }
235
+ return Object.freeze({
236
+ kind: "date-shift",
237
+ days,
238
+ representation: representation,
239
+ ...(recipe.group === undefined ? {} : { group: recipe.group }),
240
+ });
241
+ }
242
+ if (recipe.kind === "path-map") {
243
+ if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
244
+ throw new Error(`${label}.recipe contains fields not used by path-map.`);
245
+ }
246
+ if (typeof recipe.mapping !== "string" ||
247
+ !BINDING_NAME.test(recipe.mapping)) {
248
+ throw new Error(`${label}.recipe.mapping is invalid.`);
249
+ }
250
+ return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
251
+ }
252
+ if (recipe.kind === "binding-substitute") {
253
+ if (Object.keys(recipe).some((key) => !["kind", "binding", "format", "namespace", "maximumBytes"].includes(key))) {
254
+ throw new Error(`${label}.recipe contains fields not used by binding-substitute.`);
255
+ }
256
+ if (typeof recipe.binding !== "string" ||
257
+ !BINDING_NAME.test(recipe.binding)) {
258
+ throw new Error(`${label}.recipe.binding is invalid.`);
259
+ }
260
+ if (recipe.format !== "uuid") {
261
+ throw new Error(`${label}.recipe.format must be uuid.`);
262
+ }
263
+ return Object.freeze({
264
+ kind: "binding-substitute",
265
+ binding: recipe.binding,
266
+ format: "uuid",
267
+ namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
268
+ maximumBytes: positiveInteger(recipe.maximumBytes, `${label}.recipe.maximumBytes`, MAXIMUM_TEXT_SUBSTITUTION_BYTES),
269
+ });
270
+ }
271
+ if (recipe.kind === "approved-owner") {
272
+ if (Object.keys(recipe).some((key) => !["kind", "approved", "otherwise"].includes(key))) {
273
+ throw new Error(`${label}.recipe contains fields not used by approved-owner.`);
274
+ }
275
+ return Object.freeze({
276
+ kind: "approved-owner",
277
+ approved: validateNestedDeclaration(recipe.approved, `${label}.recipe.approved`, { depth }),
278
+ otherwise: validateNestedDeclaration(recipe.otherwise, `${label}.recipe.otherwise`, { depth }),
279
+ });
280
+ }
281
+ return validateStructuredJsonRecipe({
282
+ recipe,
283
+ label,
284
+ depth,
285
+ maximumPolicyDepth: MAXIMUM_POLICY_DEPTH,
286
+ validateDeclaration: validateNestedDeclaration,
287
+ validatePseudonymRecipe: (nestedRecipe, nestedLabel, nestedDepth) => validatePrivacyRecipe("PSEUDONYMIZE", nestedRecipe, nestedLabel, nestedDepth),
288
+ });
289
+ };
@@ -0,0 +1,18 @@
1
+ /** Explicit typed registry for every normalized privacy action and derivation rule. */
2
+ import type { DerivationRecipe, NormalizedDeclaration, PrivacyAction, PrivacyPathMapping } from "./privacy_contract.mjs";
3
+ interface PrivacyExecutionContext {
4
+ readonly declaration: NormalizedDeclaration;
5
+ readonly value: unknown;
6
+ readonly key: Uint8Array;
7
+ readonly path: string;
8
+ readonly ownerApproved: boolean | undefined;
9
+ readonly pathMappings: Readonly<Record<string, PrivacyPathMapping>>;
10
+ readonly bindingValues: ReadonlyMap<string, string>;
11
+ readonly resolveInput: (name: string) => unknown;
12
+ }
13
+ type ActionHandler = (context: PrivacyExecutionContext) => unknown;
14
+ type DerivationHandler = (recipe: DerivationRecipe, context: PrivacyExecutionContext) => unknown;
15
+ export declare const PRIVACY_DERIVATION_RULES: Readonly<Record<DerivationRecipe["kind"], DerivationHandler>>;
16
+ export declare const PRIVACY_ACTION_RULES: Readonly<Record<PrivacyAction, ActionHandler>>;
17
+ export declare const executePrivacyDeclaration: (context: PrivacyExecutionContext) => unknown;
18
+ export {};
@@ -0,0 +1,143 @@
1
+ /** Explicit typed registry for every normalized privacy action and derivation rule. */
2
+ import { executeStructuredJsonDeclaration } from "./privacy_structured_json.mjs";
3
+ import { privacyDigest, pseudonymize, remapPrivacyPath, substitutePrivacyBinding, } from "./privacy_transforms.mjs";
4
+ import { PORTABLE_CODE } from "./privacy_validation_shared.mjs";
5
+ const dateShift = (normalized, context) => {
6
+ const recipe = normalized;
7
+ if (context.value === null)
8
+ return null;
9
+ if (recipe.representation === "epoch-milliseconds" &&
10
+ (typeof context.value !== "number" || !Number.isSafeInteger(context.value))) {
11
+ throw new Error(`${context.path} must be a safe epoch-millisecond integer.`);
12
+ }
13
+ const date = new Date(context.value);
14
+ if (!Number.isFinite(date.valueOf())) {
15
+ throw new Error(`${context.path} is not a valid date.`);
16
+ }
17
+ const direction = recipe.group
18
+ ? privacyDigest(context.key, "date-shift-group", recipe.group)[0] % 2 === 0
19
+ ? -1
20
+ : 1
21
+ : privacyDigest(context.key, context.path, context.value)[0] % 2 === 0
22
+ ? -1
23
+ : 1;
24
+ date.setUTCDate(date.getUTCDate() + direction * recipe.days);
25
+ if (!Number.isFinite(date.valueOf())) {
26
+ throw new Error(`${context.path} shifts outside the supported date range.`);
27
+ }
28
+ return recipe.representation === "epoch-milliseconds"
29
+ ? date.valueOf()
30
+ : date.toISOString();
31
+ };
32
+ const retainEnum = (normalized, context) => {
33
+ const recipe = normalized;
34
+ if (context.value === null && recipe.allowNull)
35
+ return null;
36
+ if (!recipe.values.some((candidate) => Object.is(candidate, context.value))) {
37
+ throw new Error(`${context.path} is outside its reviewed scalar domain.`);
38
+ }
39
+ return context.value;
40
+ };
41
+ const retainValidatedString = (normalized, context) => {
42
+ const recipe = normalized;
43
+ if (context.value === null && recipe.allowNull)
44
+ return null;
45
+ if (typeof context.value !== "string" ||
46
+ Buffer.byteLength(context.value) > recipe.maximumBytes ||
47
+ !PORTABLE_CODE.test(context.value)) {
48
+ throw new Error(`${context.path} is not a valid reviewed portable code.`);
49
+ }
50
+ return context.value;
51
+ };
52
+ const deriveDigest = (normalized, context) => {
53
+ const recipe = normalized;
54
+ const inputs = recipe.inputs.map((name) => {
55
+ const sanitized = context.resolveInput(name);
56
+ if (sanitized === undefined) {
57
+ throw new Error(`${context.path} digest input ${name} was not sanitized.`);
58
+ }
59
+ return { name, value: sanitized };
60
+ });
61
+ return privacyDigest(context.key, recipe.namespace, inputs)
62
+ .toString("hex")
63
+ .slice(0, recipe.length);
64
+ };
65
+ const mapPath = (normalized, context) => {
66
+ const recipe = normalized;
67
+ const mapping = context.pathMappings[recipe.mapping];
68
+ if (!mapping) {
69
+ throw new Error(`${context.path} references an unknown privacy path mapping.`);
70
+ }
71
+ return remapPrivacyPath({
72
+ key: context.key,
73
+ mapping,
74
+ bindingValue: context.bindingValues.get(mapping.binding),
75
+ value: context.value,
76
+ label: context.path,
77
+ });
78
+ };
79
+ const substituteBinding = (normalized, context) => {
80
+ const recipe = normalized;
81
+ return substitutePrivacyBinding({
82
+ key: context.key,
83
+ recipe,
84
+ bindingValue: context.bindingValues.get(recipe.binding),
85
+ value: context.value,
86
+ label: context.path,
87
+ });
88
+ };
89
+ const chooseApprovedOwner = (normalized, context) => {
90
+ const recipe = normalized;
91
+ if (context.ownerApproved === undefined) {
92
+ throw new Error(`${context.path} has no reviewed owner condition.`);
93
+ }
94
+ return executePrivacyDeclaration({
95
+ ...context,
96
+ declaration: context.ownerApproved ? recipe.approved : recipe.otherwise,
97
+ });
98
+ };
99
+ const structuredJson = (_recipe, context) => executeStructuredJsonDeclaration({
100
+ declaration: context.declaration,
101
+ value: context.value,
102
+ key: context.key,
103
+ path: context.path,
104
+ pseudonymize: (nestedKey, nestedRecipe, nestedValue) => pseudonymize(nestedKey, nestedRecipe, nestedValue),
105
+ execute: (nestedDeclaration, nestedValue, nestedPath) => executePrivacyDeclaration({
106
+ ...context,
107
+ declaration: nestedDeclaration,
108
+ value: nestedValue,
109
+ path: nestedPath,
110
+ }),
111
+ });
112
+ export const PRIVACY_DERIVATION_RULES = Object.freeze({
113
+ "date-shift": dateShift,
114
+ enum: retainEnum,
115
+ "validated-string": retainValidatedString,
116
+ digest: deriveDigest,
117
+ "path-map": mapPath,
118
+ "binding-substitute": substituteBinding,
119
+ "approved-owner": chooseApprovedOwner,
120
+ "json-object": structuredJson,
121
+ "json-array": structuredJson,
122
+ "json-union": structuredJson,
123
+ "json-dictionary": structuredJson,
124
+ });
125
+ const derive = (context) => {
126
+ const recipe = context.declaration.recipe;
127
+ if (!recipe || !("kind" in recipe) || recipe.kind === "constant") {
128
+ throw new Error(`${context.path} has an invalid derivation recipe.`);
129
+ }
130
+ const handler = PRIVACY_DERIVATION_RULES[recipe.kind];
131
+ if (!handler) {
132
+ throw new Error(`${context.path} uses an unsupported privacy recipe.`);
133
+ }
134
+ return handler(recipe, context);
135
+ };
136
+ export const PRIVACY_ACTION_RULES = Object.freeze({
137
+ KEEP: ({ value }) => value,
138
+ EXCLUDE: () => undefined,
139
+ REPLACE: ({ declaration }) => structuredClone(declaration.recipe.value),
140
+ PSEUDONYMIZE: ({ declaration, key, value }) => pseudonymize(key, declaration.recipe, value),
141
+ DERIVE: derive,
142
+ });
143
+ export const executePrivacyDeclaration = (context) => PRIVACY_ACTION_RULES[context.declaration.action](context);
@@ -0,0 +1,18 @@
1
+ /** Deterministic primitive, identifier-shape, URL, and path transformations. */
2
+ import type { BindingSubstituteRecipe, PrivacyPathMapping, PseudonymRecipe } from "./privacy_contract.mjs";
3
+ export declare const privacyDigest: (key: Uint8Array, namespace: string, value: unknown) => Buffer;
4
+ export declare const pseudonymize: (key: Uint8Array, recipe: PseudonymRecipe, value: unknown) => unknown;
5
+ export declare const remapPrivacyPath: ({ key, mapping, bindingValue, value, label, }: {
6
+ key: Uint8Array;
7
+ mapping: PrivacyPathMapping;
8
+ bindingValue: string | undefined;
9
+ value: unknown;
10
+ label: string;
11
+ }) => string;
12
+ export declare const substitutePrivacyBinding: ({ key, recipe, bindingValue, value, label, }: {
13
+ key: Uint8Array;
14
+ recipe: BindingSubstituteRecipe;
15
+ bindingValue: string | undefined;
16
+ value: unknown;
17
+ label: string;
18
+ }) => string | null;
@@ -0,0 +1,142 @@
1
+ /** Deterministic primitive, identifier-shape, URL, and path transformations. */
2
+ import { createHmac } from "node:crypto";
3
+ import { URL_PATH_PREFIX, URL_TOKEN_LENGTH, UUID, } from "./privacy_validation_shared.mjs";
4
+ export const privacyDigest = (key, namespace, value) => {
5
+ const serialized = JSON.stringify(value);
6
+ if (serialized === undefined) {
7
+ throw new Error("Privacy input must be JSON-compatible.");
8
+ }
9
+ return createHmac("sha256", key)
10
+ .update(namespace)
11
+ .update("\0")
12
+ .update(serialized)
13
+ .digest();
14
+ };
15
+ const gtinCheckDigit = (payload) => {
16
+ const sum = [...payload]
17
+ .reverse()
18
+ .reduce((total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 3 : 1), 0);
19
+ return String((10 - (sum % 10)) % 10);
20
+ };
21
+ const isValidGtin = (value, length) => typeof value === "string" &&
22
+ value.length === length &&
23
+ /^\d+$/u.test(value) &&
24
+ value.at(-1) === gtinCheckDigit(value.slice(0, -1));
25
+ export const pseudonymize = (key, recipe, value) => {
26
+ if (value === null)
27
+ return null;
28
+ const bytes = privacyDigest(key, recipe.namespace, value);
29
+ if (recipe.format === "uuid") {
30
+ const copy = Buffer.from(bytes.subarray(0, 16));
31
+ copy[6] = (copy[6] & 0x0f) | 0x40;
32
+ copy[8] = (copy[8] & 0x3f) | 0x80;
33
+ const hex = copy.toString("hex");
34
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
35
+ }
36
+ if (recipe.format === "email") {
37
+ return `rehearsal+${bytes.toString("hex").slice(0, 24)}@example.invalid`;
38
+ }
39
+ if (recipe.format === "integer") {
40
+ return Number(bytes.readBigUInt64BE(0) % 9007199254740991n) + 1;
41
+ }
42
+ if (recipe.format === "hex") {
43
+ return bytes.toString("hex").slice(0, recipe.length);
44
+ }
45
+ if (recipe.format === "gtin") {
46
+ const allowedLengths = recipe.allowedLengths ??
47
+ (recipe.length === undefined ? [] : [recipe.length]);
48
+ const sourceLength = typeof value === "string" ? value.length : null;
49
+ if (sourceLength === null ||
50
+ !allowedLengths.includes(sourceLength) ||
51
+ !isValidGtin(value, sourceLength)) {
52
+ if (recipe.length !== undefined) {
53
+ throw new Error(`A gtin pseudonym source must be a valid normalized GTIN-${recipe.length} string.`);
54
+ }
55
+ throw new Error(`A gtin pseudonym source must be a valid normalized GTIN string with an allowed length (${allowedLengths.join(", ")}).`);
56
+ }
57
+ const modulus = 10n ** BigInt(sourceLength - 1);
58
+ const payload = (BigInt(`0x${bytes.toString("hex")}`) % modulus)
59
+ .toString()
60
+ .padStart(sourceLength - 1, "0");
61
+ return `${payload}${gtinCheckDigit(payload)}`;
62
+ }
63
+ if (recipe.format === "url") {
64
+ if (typeof value !== "string" ||
65
+ value.trim() !== value ||
66
+ /\s/u.test(value)) {
67
+ throw new Error("A url pseudonym source must be a valid HTTPS URL.");
68
+ }
69
+ let source;
70
+ try {
71
+ source = new URL(value);
72
+ }
73
+ catch {
74
+ throw new Error("A url pseudonym source must be a valid HTTPS URL.");
75
+ }
76
+ if (source.protocol !== "https:") {
77
+ throw new Error("A url pseudonym source must be a valid HTTPS URL.");
78
+ }
79
+ const output = `${recipe.origin}${URL_PATH_PREFIX}${bytes
80
+ .toString("hex")
81
+ .slice(0, URL_TOKEN_LENGTH)}`;
82
+ if (output.length > recipe.maxLength) {
83
+ throw new Error("A url pseudonym exceeded its reviewed maximum length.");
84
+ }
85
+ return output;
86
+ }
87
+ const token = `rehearsal_${bytes.toString("hex")}`;
88
+ return token.slice(0, recipe.maxLength);
89
+ };
90
+ export const remapPrivacyPath = ({ key, mapping, bindingValue, value, label, }) => {
91
+ if (typeof value !== "string") {
92
+ throw new Error(`${label} must be a Storage-style path string.`);
93
+ }
94
+ if (Buffer.byteLength(value) > mapping.maximumBytes) {
95
+ throw new Error(`${label} exceeds its reviewed byte limit.`);
96
+ }
97
+ const segments = value.split("/");
98
+ if (!value ||
99
+ value.startsWith("/") ||
100
+ value.endsWith("/") ||
101
+ segments.length > mapping.maximumSegments ||
102
+ segments.some((segment) => !segment || segment === "." || segment === "..")) {
103
+ throw new Error(`${label} is not a safe bounded Storage path.`);
104
+ }
105
+ if (typeof bindingValue !== "string" ||
106
+ !bindingValue ||
107
+ bindingValue.includes("/") ||
108
+ bindingValue === "." ||
109
+ bindingValue === "..") {
110
+ throw new Error(`${label} has an invalid reviewed identity binding.`);
111
+ }
112
+ if (segments[0] !== bindingValue) {
113
+ throw new Error(`${label} does not begin with its exact reviewed identity.`);
114
+ }
115
+ const destination = [
116
+ String(pseudonymize(key, mapping, bindingValue)),
117
+ ...segments.slice(1),
118
+ ].join("/");
119
+ if (Buffer.byteLength(destination) > mapping.maximumBytes) {
120
+ throw new Error(`${label} destination exceeds its reviewed byte limit.`);
121
+ }
122
+ return destination;
123
+ };
124
+ export const substitutePrivacyBinding = ({ key, recipe, bindingValue, value, label, }) => {
125
+ if (typeof bindingValue !== "string" || !UUID.test(bindingValue)) {
126
+ throw new Error(`${label} has an invalid reviewed UUID binding.`);
127
+ }
128
+ if (value === null)
129
+ return null;
130
+ if (typeof value !== "string") {
131
+ throw new Error(`${label} must be text or null for binding substitution.`);
132
+ }
133
+ if (Buffer.byteLength(value) > recipe.maximumBytes) {
134
+ throw new Error(`${label} exceeds its reviewed byte limit.`);
135
+ }
136
+ const replacement = String(pseudonymize(key, recipe, bindingValue));
137
+ const destination = value.replaceAll(bindingValue, replacement);
138
+ if (Buffer.byteLength(destination) > recipe.maximumBytes) {
139
+ throw new Error(`${label} destination exceeds its reviewed byte limit.`);
140
+ }
141
+ return destination;
142
+ };
@@ -0,0 +1,31 @@
1
+ /** Redacted primitive validators and closed vocabulary for privacy policy v2. */
2
+ import type { PrivacyObject } from "./privacy_contract.mjs";
3
+ export declare const IDENTIFIER: RegExp;
4
+ export declare const BINDING_NAME: RegExp;
5
+ export declare const ENVIRONMENT_KEY: RegExp;
6
+ export declare const HEX_64: RegExp;
7
+ export declare const UUID: RegExp;
8
+ export declare const PORTABLE_CODE: RegExp;
9
+ export declare const ACTIONS: Set<string>;
10
+ export declare const PSEUDONYM_FORMATS: Set<string>;
11
+ export declare const HEX_LENGTHS: Set<number>;
12
+ export declare const GTIN_LENGTHS: Set<number>;
13
+ export declare const DATE_REPRESENTATIONS: Set<string>;
14
+ export declare const DERIVATION_KINDS: Set<string>;
15
+ export declare const URL_PATH_PREFIX = "/rehearsal/";
16
+ export declare const URL_TOKEN_LENGTH = 32;
17
+ export declare const MAXIMUM_URL_LENGTH = 2048;
18
+ export declare const MAXIMUM_TEXT_SUBSTITUTION_BYTES = 65536;
19
+ export declare const LOOPBACK_HOSTNAMES: Set<string>;
20
+ export declare const DEFAULT_PATH_MAXIMUM_BYTES = 2048;
21
+ export declare const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
22
+ export declare const MAXIMUM_POLICY_DEPTH = 16;
23
+ export declare const MAXIMUM_ENUM_VALUES = 128;
24
+ export declare const MAXIMUM_ENUM_STRING_BYTES = 256;
25
+ export declare const isPrivacyObject: (value: unknown) => value is PrivacyObject;
26
+ export declare const assertKnownKeys: (value: PrivacyObject, allowed: readonly string[], label: string) => void;
27
+ export declare const validateIdentifier: (value: unknown, label: string) => string;
28
+ export declare const validateNonEmpty: (value: unknown, label: string) => string;
29
+ export declare const validatePositiveInteger: (value: unknown, label: string, maximum?: number) => number;
30
+ export declare const validateReviewedScalar: (value: unknown, label: string) => string | number | boolean;
31
+ export declare const validateHttpsLoopbackOrigin: (value: unknown, label: string) => string;