@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/README.md +4 -1
- package/dist/package.json +100 -0
- package/dist/scripts/install/create_root_config.d.mts +3 -0
- package/dist/scripts/install/create_root_config.mjs +13 -0
- package/dist/scripts/runtime/manage_database.d.mts +6 -0
- package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
- package/dist/src/application/session.d.mts +96 -0
- package/dist/src/application/session.mjs +380 -0
- package/dist/src/baseline/artifact.d.mts +119 -0
- package/dist/src/baseline/artifact.mjs +522 -0
- package/dist/src/baseline/builder.d.mts +19 -0
- package/dist/src/baseline/builder.mjs +142 -0
- package/dist/src/baseline/input_discovery.d.mts +13 -0
- package/dist/src/baseline/input_discovery.mjs +149 -0
- package/dist/src/baseline/policy_review.d.mts +56 -0
- package/dist/src/baseline/policy_review.mjs +73 -0
- package/dist/src/baseline/preparation.d.mts +63 -0
- package/dist/src/baseline/preparation.mjs +295 -0
- package/dist/src/baseline/privacy_engine.d.mts +162 -0
- package/dist/src/baseline/privacy_engine.mjs +1162 -0
- package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
- package/dist/src/baseline/privacy_structured_json.mjs +464 -0
- package/dist/src/baseline/sanitization_policy.d.mts +69 -0
- package/dist/src/baseline/sanitization_policy.mjs +227 -0
- package/dist/src/baseline/schema_snapshot.d.mts +19 -0
- package/dist/src/baseline/schema_snapshot.mjs +79 -0
- package/dist/src/cli/arguments.d.mts +43 -0
- package/dist/src/cli/arguments.mjs +135 -0
- package/dist/src/cli/guided.d.mts +51 -0
- package/dist/src/cli/guided.mjs +713 -0
- package/dist/src/cli/init_command.d.mts +21 -0
- package/dist/src/cli/init_command.mjs +90 -0
- package/dist/src/cli/project_commands.d.mts +157 -0
- package/dist/src/cli/project_commands.mjs +183 -0
- package/dist/src/cli/rehearsal.d.mts +7 -0
- package/dist/src/cli/rehearsal.mjs +706 -0
- package/dist/src/cli/renderers.d.mts +299 -0
- package/dist/src/cli/renderers.mjs +554 -0
- package/dist/src/cli/runtime_commands.d.mts +265 -0
- package/dist/src/cli/runtime_commands.mjs +599 -0
- package/dist/src/cli/source_commands.d.mts +432 -0
- package/dist/src/cli/source_commands.mjs +299 -0
- package/dist/src/cli/terminal.d.mts +59 -0
- package/dist/src/cli/terminal.mjs +230 -0
- package/dist/src/identity/claim.d.mts +144 -0
- package/dist/src/identity/claim.mjs +947 -0
- package/dist/src/identity/provider_configuration.d.mts +37 -0
- package/dist/src/identity/provider_configuration.mjs +132 -0
- package/dist/src/identity/storage.d.mts +21 -0
- package/dist/src/identity/storage.mjs +122 -0
- package/dist/src/index.d.mts +4 -0
- package/dist/src/index.mjs +3 -0
- package/dist/src/project/configuration.d.mts +181 -0
- package/dist/src/project/configuration.mjs +958 -0
- package/dist/src/project/install_scaffold.d.mts +42 -0
- package/dist/src/project/install_scaffold.mjs +263 -0
- package/dist/src/project/setup.d.mts +87 -0
- package/dist/src/project/setup.mjs +414 -0
- package/dist/src/project/support_report.d.mts +88 -0
- package/dist/src/project/support_report.mjs +83 -0
- package/dist/src/runtime/cleanup.d.mts +85 -0
- package/dist/src/runtime/cleanup.mjs +311 -0
- package/dist/src/runtime/migration_history.d.mts +51 -0
- package/dist/src/runtime/migration_history.mjs +170 -0
- package/dist/src/runtime/plan.d.mts +134 -0
- package/dist/src/runtime/plan.mjs +636 -0
- package/dist/src/runtime/policy.d.mts +66 -0
- package/dist/src/runtime/policy.mjs +369 -0
- package/dist/src/runtime/restore.d.mts +22 -0
- package/dist/src/runtime/restore.mjs +276 -0
- package/dist/src/runtime/service_environment.d.mts +11 -0
- package/dist/src/runtime/service_environment.mjs +58 -0
- package/dist/src/runtime/topology.d.mts +51 -0
- package/dist/src/runtime/topology.mjs +128 -0
- package/dist/src/shared/diagnostics.d.mts +97 -0
- package/dist/src/shared/diagnostics.mjs +170 -0
- package/dist/src/shared/human_output.d.mts +2 -0
- package/{src → dist/src}/shared/human_output.mjs +1 -3
- package/dist/src/shared/node_runtime.d.mts +14 -0
- package/dist/src/shared/node_runtime.mjs +25 -0
- package/dist/src/shared/operation_guard.d.mts +22 -0
- package/dist/src/shared/operation_guard.mjs +150 -0
- package/dist/src/shared/process_environment.d.mts +15 -0
- package/dist/src/shared/process_environment.mjs +50 -0
- package/dist/src/source/access.d.mts +165 -0
- package/dist/src/source/access.mjs +602 -0
- package/dist/src/source/asset_transfer.d.mts +27 -0
- package/dist/src/source/asset_transfer.mjs +202 -0
- package/dist/src/source/baseline.d.mts +66 -0
- package/dist/src/source/baseline.mjs +372 -0
- package/dist/src/source/external_reader.d.mts +42 -0
- package/dist/src/source/external_reader.mjs +278 -0
- package/dist/src/source/postgresql_access.d.mts +86 -0
- package/dist/src/source/postgresql_access.mjs +454 -0
- package/dist/src/source/privacy_paths.d.mts +10 -0
- package/dist/src/source/privacy_paths.mjs +27 -0
- package/dist/src/targets/driver.d.mts +2 -0
- package/dist/src/targets/driver.mjs +13 -0
- package/dist/src/targets/postgresql.d.mts +5 -0
- package/dist/src/targets/postgresql.mjs +641 -0
- package/dist/src/targets/supabase.d.mts +8 -0
- package/dist/src/targets/supabase.mjs +659 -0
- package/dist/src/targets/supabase_environment.d.mts +49 -0
- package/dist/src/targets/supabase_environment.mjs +162 -0
- package/dist/src/targets/target.d.mts +33 -0
- package/dist/src/targets/target.mjs +39 -0
- package/docs/README.md +1 -0
- package/docs/architecture.md +17 -9
- package/docs/configuration.md +49 -8
- package/docs/getting-started.md +28 -0
- package/docs/index.md +1 -0
- package/docs/production-source.md +43 -7
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +415 -14
- package/docs/standalone-workflow.md +12 -6
- package/docs/troubleshooting.md +5 -1
- package/docs/tutorial.md +5 -0
- package/docs/typescript.md +87 -0
- package/package.json +35 -29
- package/scripts/install/create_root_config.mjs +0 -36
- package/src/README.md +0 -17
- package/src/application/session.mjs +0 -435
- package/src/baseline/artifact.mjs +0 -684
- package/src/baseline/builder.mjs +0 -164
- package/src/baseline/input_discovery.mjs +0 -155
- package/src/baseline/policy_review.mjs +0 -92
- package/src/baseline/preparation.mjs +0 -323
- package/src/baseline/privacy_engine.mjs +0 -952
- package/src/baseline/sanitization_policy.mjs +0 -299
- package/src/baseline/schema_snapshot.mjs +0 -113
- package/src/cli/arguments.mjs +0 -126
- package/src/cli/guided.mjs +0 -814
- package/src/cli/init_command.mjs +0 -94
- package/src/cli/rehearsal.mjs +0 -970
- package/src/cli/renderers.mjs +0 -626
- package/src/cli/runtime_commands.mjs +0 -707
- package/src/cli/source_commands.mjs +0 -347
- package/src/cli/terminal.mjs +0 -275
- package/src/identity/claim.mjs +0 -1163
- package/src/identity/provider_configuration.mjs +0 -177
- package/src/identity/storage.mjs +0 -165
- package/src/project/configuration.mjs +0 -1337
- package/src/project/install_scaffold.mjs +0 -291
- package/src/project/setup.mjs +0 -466
- package/src/project/support_report.mjs +0 -105
- package/src/runtime/cleanup.mjs +0 -381
- package/src/runtime/migration_history.mjs +0 -220
- package/src/runtime/plan.mjs +0 -722
- package/src/runtime/policy.mjs +0 -438
- package/src/runtime/restore.mjs +0 -337
- package/src/runtime/service_environment.mjs +0 -82
- package/src/runtime/topology.mjs +0 -177
- package/src/shared/diagnostics.mjs +0 -207
- package/src/shared/node_runtime.mjs +0 -31
- package/src/shared/operation_guard.mjs +0 -162
- package/src/shared/process_environment.mjs +0 -68
- package/src/source/access.mjs +0 -753
- package/src/source/asset_transfer.mjs +0 -207
- package/src/source/baseline.mjs +0 -482
- package/src/source/external_reader.mjs +0 -255
- package/src/source/postgresql_access.mjs +0 -581
- package/src/source/privacy_paths.mjs +0 -38
- package/src/targets/postgresql.mjs +0 -808
- package/src/targets/supabase.mjs +0 -840
- package/src/targets/supabase_environment.mjs +0 -229
- package/src/targets/target.mjs +0 -66
- /package/{src/project → types}/configuration.d.mts +0 -0
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute reviewed privacy policy version 2 without project-owned callbacks.
|
|
3
|
+
* The engine is intentionally small and fail-closed: unsupported shapes and
|
|
4
|
+
* recipes must be reviewed and added here instead of evaluated as code.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
7
|
+
import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { STRUCTURED_JSON_KINDS, executeStructuredJsonDeclaration, structuredJsonDeclarations, validateStructuredJsonRecipe, } from "./privacy_structured_json.mjs";
|
|
10
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
11
|
+
const BINDING_NAME = /^[a-z][a-z0-9-]{0,62}$/u;
|
|
12
|
+
const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
13
|
+
const HEX_64 = /^[a-f0-9]{64}$/u;
|
|
14
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
|
|
15
|
+
const PORTABLE_CODE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
|
16
|
+
const ACTIONS = new Set([
|
|
17
|
+
"KEEP",
|
|
18
|
+
"PSEUDONYMIZE",
|
|
19
|
+
"REPLACE",
|
|
20
|
+
"EXCLUDE",
|
|
21
|
+
"DERIVE",
|
|
22
|
+
]);
|
|
23
|
+
const PSEUDONYM_FORMATS = new Set([
|
|
24
|
+
"uuid",
|
|
25
|
+
"email",
|
|
26
|
+
"text",
|
|
27
|
+
"integer",
|
|
28
|
+
"hex",
|
|
29
|
+
"gtin",
|
|
30
|
+
"url",
|
|
31
|
+
]);
|
|
32
|
+
const HEX_LENGTHS = new Set([32, 64]);
|
|
33
|
+
const GTIN_LENGTHS = new Set([8, 12, 13, 14]);
|
|
34
|
+
const DATE_REPRESENTATIONS = new Set(["iso-string", "epoch-milliseconds"]);
|
|
35
|
+
const DERIVATION_KINDS = new Set([
|
|
36
|
+
"date-shift",
|
|
37
|
+
"digest",
|
|
38
|
+
"enum",
|
|
39
|
+
"validated-string",
|
|
40
|
+
"path-map",
|
|
41
|
+
"json-object",
|
|
42
|
+
"json-array",
|
|
43
|
+
"json-union",
|
|
44
|
+
"json-dictionary",
|
|
45
|
+
"approved-owner",
|
|
46
|
+
"binding-substitute",
|
|
47
|
+
]);
|
|
48
|
+
const KEY_BYTES = 32;
|
|
49
|
+
const URL_PATH_PREFIX = "/rehearsal/";
|
|
50
|
+
const URL_TOKEN_LENGTH = 32;
|
|
51
|
+
const MAXIMUM_URL_LENGTH = 2_048;
|
|
52
|
+
const MAXIMUM_TEXT_SUBSTITUTION_BYTES = 65_536;
|
|
53
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
54
|
+
const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
|
|
55
|
+
const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
|
|
56
|
+
const MAXIMUM_POLICY_DEPTH = 16;
|
|
57
|
+
const MAXIMUM_ENUM_VALUES = 128;
|
|
58
|
+
const MAXIMUM_ENUM_STRING_BYTES = 256;
|
|
59
|
+
const isObject = (value) => value !== null &&
|
|
60
|
+
typeof value === "object" &&
|
|
61
|
+
!Array.isArray(value) &&
|
|
62
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
63
|
+
const knownKeys = (value, allowed, label) => {
|
|
64
|
+
for (const key of Object.keys(value)) {
|
|
65
|
+
if (!allowed.includes(key))
|
|
66
|
+
throw new Error(`${label}.${key} is unknown.`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const identifier = (value, label) => {
|
|
70
|
+
if (typeof value !== "string" || !IDENTIFIER.test(value)) {
|
|
71
|
+
throw new Error(`${label} must be a lowercase PostgreSQL identifier.`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
};
|
|
75
|
+
const nonEmpty = (value, label) => {
|
|
76
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
77
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
78
|
+
}
|
|
79
|
+
return value.trim();
|
|
80
|
+
};
|
|
81
|
+
const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
|
|
82
|
+
if (typeof value !== "number" ||
|
|
83
|
+
!Number.isSafeInteger(value) ||
|
|
84
|
+
value < 1 ||
|
|
85
|
+
value > maximum) {
|
|
86
|
+
throw new Error(`${label} must be an integer from 1 through ${maximum}.`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
};
|
|
90
|
+
const reviewedScalar = (value, label) => {
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
if (Buffer.byteLength(value) > MAXIMUM_ENUM_STRING_BYTES) {
|
|
93
|
+
throw new Error(`${label} must contain at most ${MAXIMUM_ENUM_STRING_BYTES} UTF-8 bytes.`);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === "boolean")
|
|
98
|
+
return value;
|
|
99
|
+
if (typeof value === "number" &&
|
|
100
|
+
Number.isFinite(value) &&
|
|
101
|
+
!Object.is(value, -0)) {
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`${label} must be a JSON string, finite number, or boolean.`);
|
|
105
|
+
};
|
|
106
|
+
const httpsLoopbackOrigin = (value, label) => {
|
|
107
|
+
if (typeof value !== "string" || value.trim() !== value || !value) {
|
|
108
|
+
throw new Error(`${label} must be a safe HTTPS loopback origin.`);
|
|
109
|
+
}
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = new URL(value);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new Error(`${label} must be a safe HTTPS loopback origin.`);
|
|
116
|
+
}
|
|
117
|
+
if (parsed.protocol !== "https:" ||
|
|
118
|
+
!LOOPBACK_HOSTNAMES.has(parsed.hostname) ||
|
|
119
|
+
![parsed.origin, `${parsed.origin}/`].includes(value) ||
|
|
120
|
+
parsed.username ||
|
|
121
|
+
parsed.password ||
|
|
122
|
+
(parsed.pathname !== "/" && parsed.pathname !== "") ||
|
|
123
|
+
parsed.search ||
|
|
124
|
+
parsed.hash) {
|
|
125
|
+
throw new Error(`${label} must be a safe HTTPS loopback origin.`);
|
|
126
|
+
}
|
|
127
|
+
return parsed.origin;
|
|
128
|
+
};
|
|
129
|
+
const validateNestedDeclaration = (declaration, label, { allowRequired = false, depth = 0, } = {}) => {
|
|
130
|
+
if (!isObject(declaration))
|
|
131
|
+
throw new Error(`${label} must be an object.`);
|
|
132
|
+
knownKeys(declaration, allowRequired ? ["action", "recipe", "required"] : ["action", "recipe"], label);
|
|
133
|
+
if (allowRequired &&
|
|
134
|
+
declaration.required !== undefined &&
|
|
135
|
+
typeof declaration.required !== "boolean") {
|
|
136
|
+
throw new Error(`${label}.required must be true or false.`);
|
|
137
|
+
}
|
|
138
|
+
const action = String(declaration.action ?? "").toUpperCase();
|
|
139
|
+
if (!ACTIONS.has(action))
|
|
140
|
+
throw new Error(`${label}.action is unsupported.`);
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
action: action,
|
|
143
|
+
recipe: validateRecipe(action, declaration.recipe, label, depth + 1),
|
|
144
|
+
...(allowRequired ? { required: declaration.required !== false } : {}),
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
148
|
+
if (depth > MAXIMUM_POLICY_DEPTH) {
|
|
149
|
+
throw new Error(`${label}.recipe exceeds the supported nesting depth.`);
|
|
150
|
+
}
|
|
151
|
+
if (action === "KEEP" || action === "EXCLUDE") {
|
|
152
|
+
if (recipe !== undefined && recipe !== null) {
|
|
153
|
+
throw new Error(`${label}.recipe is not allowed for ${action}.`);
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (!isObject(recipe)) {
|
|
158
|
+
throw new Error(`${label}.recipe is required for ${action}.`);
|
|
159
|
+
}
|
|
160
|
+
if (action === "PSEUDONYMIZE") {
|
|
161
|
+
knownKeys(recipe, [
|
|
162
|
+
"format",
|
|
163
|
+
"namespace",
|
|
164
|
+
"maxLength",
|
|
165
|
+
"length",
|
|
166
|
+
"allowedLengths",
|
|
167
|
+
"origin",
|
|
168
|
+
], `${label}.recipe`);
|
|
169
|
+
if (typeof recipe.format !== "string" ||
|
|
170
|
+
!PSEUDONYM_FORMATS.has(recipe.format)) {
|
|
171
|
+
throw new Error(`${label}.recipe.format is unsupported.`);
|
|
172
|
+
}
|
|
173
|
+
const normalized = {
|
|
174
|
+
format: recipe.format,
|
|
175
|
+
namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
|
|
176
|
+
};
|
|
177
|
+
if (recipe.format === "text") {
|
|
178
|
+
normalized.maxLength = positiveInteger(recipe.maxLength, `${label}.recipe.maxLength`, 1_024);
|
|
179
|
+
}
|
|
180
|
+
else if (recipe.format === "url") {
|
|
181
|
+
normalized.origin = httpsLoopbackOrigin(recipe.origin, `${label}.recipe.origin`);
|
|
182
|
+
normalized.maxLength = positiveInteger(recipe.maxLength, `${label}.recipe.maxLength`, MAXIMUM_URL_LENGTH);
|
|
183
|
+
const outputLength = normalized.origin.length + URL_PATH_PREFIX.length + URL_TOKEN_LENGTH;
|
|
184
|
+
if (normalized.maxLength < outputLength) {
|
|
185
|
+
throw new Error(`${label}.recipe.maxLength must allow the complete pseudonymous URL (${outputLength} characters).`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (recipe.maxLength !== undefined) {
|
|
189
|
+
throw new Error(`${label}.recipe.maxLength is supported only for text and url pseudonyms.`);
|
|
190
|
+
}
|
|
191
|
+
if (recipe.format === "hex") {
|
|
192
|
+
if (typeof recipe.length !== "number" ||
|
|
193
|
+
!HEX_LENGTHS.has(recipe.length)) {
|
|
194
|
+
throw new Error(`${label}.recipe.length must be 32 or 64 for hex.`);
|
|
195
|
+
}
|
|
196
|
+
normalized.length = recipe.length;
|
|
197
|
+
}
|
|
198
|
+
else if (recipe.format === "gtin") {
|
|
199
|
+
const hasLength = Object.hasOwn(recipe, "length");
|
|
200
|
+
const hasAllowedLengths = Object.hasOwn(recipe, "allowedLengths");
|
|
201
|
+
if (hasLength === hasAllowedLengths) {
|
|
202
|
+
throw new Error(`${label}.recipe must declare exactly one of length or allowedLengths for gtin.`);
|
|
203
|
+
}
|
|
204
|
+
if (hasLength) {
|
|
205
|
+
if (typeof recipe.length !== "number" ||
|
|
206
|
+
!GTIN_LENGTHS.has(recipe.length)) {
|
|
207
|
+
throw new Error(`${label}.recipe.length must be 8, 12, 13, or 14 for gtin.`);
|
|
208
|
+
}
|
|
209
|
+
normalized.length = recipe.length;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
if (!Array.isArray(recipe.allowedLengths) ||
|
|
213
|
+
recipe.allowedLengths.length === 0) {
|
|
214
|
+
throw new Error(`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`);
|
|
215
|
+
}
|
|
216
|
+
const allowedLengths = recipe.allowedLengths.map((length) => {
|
|
217
|
+
if (typeof length !== "number" || !GTIN_LENGTHS.has(length)) {
|
|
218
|
+
throw new Error(`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`);
|
|
219
|
+
}
|
|
220
|
+
return length;
|
|
221
|
+
});
|
|
222
|
+
if (new Set(allowedLengths).size !== allowedLengths.length) {
|
|
223
|
+
throw new Error(`${label}.recipe.allowedLengths must not contain duplicates.`);
|
|
224
|
+
}
|
|
225
|
+
normalized.allowedLengths = Object.freeze(allowedLengths);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else if (recipe.length !== undefined ||
|
|
229
|
+
recipe.allowedLengths !== undefined ||
|
|
230
|
+
(recipe.origin !== undefined && recipe.format !== "url")) {
|
|
231
|
+
throw new Error(`${label}.recipe shape controls do not match its pseudonym format.`);
|
|
232
|
+
}
|
|
233
|
+
return Object.freeze(normalized);
|
|
234
|
+
}
|
|
235
|
+
if (action === "REPLACE") {
|
|
236
|
+
knownKeys(recipe, ["kind", "value"], `${label}.recipe`);
|
|
237
|
+
if (recipe.kind !== "constant" || !Object.hasOwn(recipe, "value")) {
|
|
238
|
+
throw new Error(`${label}.recipe must be an explicit constant.`);
|
|
239
|
+
}
|
|
240
|
+
if (typeof recipe.value === "function" || recipe.value === undefined) {
|
|
241
|
+
throw new Error(`${label}.recipe.value must be JSON-compatible.`);
|
|
242
|
+
}
|
|
243
|
+
JSON.stringify(recipe.value);
|
|
244
|
+
return Object.freeze({ kind: "constant", value: recipe.value });
|
|
245
|
+
}
|
|
246
|
+
knownKeys(recipe, [
|
|
247
|
+
"kind",
|
|
248
|
+
"days",
|
|
249
|
+
"group",
|
|
250
|
+
"representation",
|
|
251
|
+
"format",
|
|
252
|
+
"length",
|
|
253
|
+
"namespace",
|
|
254
|
+
"inputs",
|
|
255
|
+
"mapping",
|
|
256
|
+
"binding",
|
|
257
|
+
"fields",
|
|
258
|
+
"items",
|
|
259
|
+
"variants",
|
|
260
|
+
"keys",
|
|
261
|
+
"values",
|
|
262
|
+
"approved",
|
|
263
|
+
"otherwise",
|
|
264
|
+
"allowNull",
|
|
265
|
+
"maximumBytes",
|
|
266
|
+
"maximumDepth",
|
|
267
|
+
"maximumItems",
|
|
268
|
+
], `${label}.recipe`);
|
|
269
|
+
if (typeof recipe.kind !== "string" || !DERIVATION_KINDS.has(recipe.kind)) {
|
|
270
|
+
throw new Error(`${label}.recipe.kind is unsupported.`);
|
|
271
|
+
}
|
|
272
|
+
if (recipe.kind === "enum") {
|
|
273
|
+
if (Object.keys(recipe).some((key) => !["kind", "values", "allowNull"].includes(key))) {
|
|
274
|
+
throw new Error(`${label}.recipe contains fields not used by enum.`);
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(recipe.values) ||
|
|
277
|
+
recipe.values.length === 0 ||
|
|
278
|
+
recipe.values.length > MAXIMUM_ENUM_VALUES) {
|
|
279
|
+
throw new Error(`${label}.recipe.values must contain 1 through ${MAXIMUM_ENUM_VALUES} reviewed scalars.`);
|
|
280
|
+
}
|
|
281
|
+
if (recipe.allowNull !== undefined &&
|
|
282
|
+
typeof recipe.allowNull !== "boolean") {
|
|
283
|
+
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
284
|
+
}
|
|
285
|
+
const values = Array.from(recipe.values, (value, index) => reviewedScalar(value, `${label}.recipe.values[${index}]`));
|
|
286
|
+
const identities = values.map((value) => JSON.stringify([typeof value, value]));
|
|
287
|
+
if (new Set(identities).size !== identities.length) {
|
|
288
|
+
throw new Error(`${label}.recipe.values must not contain duplicates.`);
|
|
289
|
+
}
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
kind: "enum",
|
|
292
|
+
allowNull: recipe.allowNull === true,
|
|
293
|
+
values: Object.freeze(values),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (recipe.kind === "validated-string") {
|
|
297
|
+
if (Object.keys(recipe).some((key) => !["kind", "format", "maximumBytes", "allowNull"].includes(key))) {
|
|
298
|
+
throw new Error(`${label}.recipe contains fields not used by validated-string.`);
|
|
299
|
+
}
|
|
300
|
+
if (recipe.format !== "portable-code") {
|
|
301
|
+
throw new Error(`${label}.recipe.format must be portable-code for validated-string.`);
|
|
302
|
+
}
|
|
303
|
+
const maximumBytes = positiveInteger(recipe.maximumBytes, `${label}.recipe.maximumBytes`, 1_024);
|
|
304
|
+
if (recipe.allowNull !== undefined &&
|
|
305
|
+
typeof recipe.allowNull !== "boolean") {
|
|
306
|
+
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
307
|
+
}
|
|
308
|
+
return Object.freeze({
|
|
309
|
+
kind: "validated-string",
|
|
310
|
+
format: "portable-code",
|
|
311
|
+
maximumBytes,
|
|
312
|
+
allowNull: recipe.allowNull === true,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (recipe.kind === "digest") {
|
|
316
|
+
if (Object.keys(recipe).some((key) => !["kind", "format", "length", "namespace", "inputs"].includes(key))) {
|
|
317
|
+
throw new Error(`${label}.recipe contains fields not used by digest.`);
|
|
318
|
+
}
|
|
319
|
+
if (recipe.format !== "hex") {
|
|
320
|
+
throw new Error(`${label}.recipe.format must be hex for digest.`);
|
|
321
|
+
}
|
|
322
|
+
if (typeof recipe.length !== "number" || !HEX_LENGTHS.has(recipe.length)) {
|
|
323
|
+
throw new Error(`${label}.recipe.length must be 32 or 64 for digest.`);
|
|
324
|
+
}
|
|
325
|
+
if (!Array.isArray(recipe.inputs) ||
|
|
326
|
+
recipe.inputs.length === 0 ||
|
|
327
|
+
recipe.inputs.length > 32) {
|
|
328
|
+
throw new Error(`${label}.recipe.inputs must contain 1 through 32 columns.`);
|
|
329
|
+
}
|
|
330
|
+
const inputs = recipe.inputs.map((input, index) => identifier(input, `${label}.recipe.inputs[${index}]`));
|
|
331
|
+
if (new Set(inputs).size !== inputs.length) {
|
|
332
|
+
throw new Error(`${label}.recipe.inputs must not contain duplicates.`);
|
|
333
|
+
}
|
|
334
|
+
return Object.freeze({
|
|
335
|
+
kind: "digest",
|
|
336
|
+
format: "hex",
|
|
337
|
+
length: recipe.length,
|
|
338
|
+
namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
|
|
339
|
+
inputs: Object.freeze(inputs),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (recipe.kind === "date-shift") {
|
|
343
|
+
const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
|
|
344
|
+
if (Object.keys(recipe).some((key) => !["kind", "days", "group", "representation"].includes(key))) {
|
|
345
|
+
throw new Error(`${label}.recipe contains fields not used by date-shift.`);
|
|
346
|
+
}
|
|
347
|
+
if (recipe.group !== undefined &&
|
|
348
|
+
(typeof recipe.group !== "string" || !BINDING_NAME.test(recipe.group))) {
|
|
349
|
+
throw new Error(`${label}.recipe.group is invalid.`);
|
|
350
|
+
}
|
|
351
|
+
const representation = recipe.representation === undefined
|
|
352
|
+
? "iso-string"
|
|
353
|
+
: recipe.representation;
|
|
354
|
+
if (typeof representation !== "string" ||
|
|
355
|
+
!DATE_REPRESENTATIONS.has(representation)) {
|
|
356
|
+
throw new Error(`${label}.recipe.representation must be iso-string or epoch-milliseconds.`);
|
|
357
|
+
}
|
|
358
|
+
return Object.freeze({
|
|
359
|
+
kind: "date-shift",
|
|
360
|
+
days,
|
|
361
|
+
representation: representation,
|
|
362
|
+
...(recipe.group === undefined ? {} : { group: recipe.group }),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (recipe.kind === "path-map") {
|
|
366
|
+
if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
|
|
367
|
+
throw new Error(`${label}.recipe contains fields not used by path-map.`);
|
|
368
|
+
}
|
|
369
|
+
if (typeof recipe.mapping !== "string" ||
|
|
370
|
+
!BINDING_NAME.test(recipe.mapping)) {
|
|
371
|
+
throw new Error(`${label}.recipe.mapping is invalid.`);
|
|
372
|
+
}
|
|
373
|
+
return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
|
|
374
|
+
}
|
|
375
|
+
if (recipe.kind === "binding-substitute") {
|
|
376
|
+
if (Object.keys(recipe).some((key) => !["kind", "binding", "format", "namespace", "maximumBytes"].includes(key))) {
|
|
377
|
+
throw new Error(`${label}.recipe contains fields not used by binding-substitute.`);
|
|
378
|
+
}
|
|
379
|
+
if (typeof recipe.binding !== "string" ||
|
|
380
|
+
!BINDING_NAME.test(recipe.binding)) {
|
|
381
|
+
throw new Error(`${label}.recipe.binding is invalid.`);
|
|
382
|
+
}
|
|
383
|
+
if (recipe.format !== "uuid") {
|
|
384
|
+
throw new Error(`${label}.recipe.format must be uuid.`);
|
|
385
|
+
}
|
|
386
|
+
return Object.freeze({
|
|
387
|
+
kind: "binding-substitute",
|
|
388
|
+
binding: recipe.binding,
|
|
389
|
+
format: "uuid",
|
|
390
|
+
namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
|
|
391
|
+
maximumBytes: positiveInteger(recipe.maximumBytes, `${label}.recipe.maximumBytes`, MAXIMUM_TEXT_SUBSTITUTION_BYTES),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (recipe.kind === "approved-owner") {
|
|
395
|
+
if (Object.keys(recipe).some((key) => !["kind", "approved", "otherwise"].includes(key))) {
|
|
396
|
+
throw new Error(`${label}.recipe contains fields not used by approved-owner.`);
|
|
397
|
+
}
|
|
398
|
+
return Object.freeze({
|
|
399
|
+
kind: "approved-owner",
|
|
400
|
+
approved: validateNestedDeclaration(recipe.approved, `${label}.recipe.approved`, { depth }),
|
|
401
|
+
otherwise: validateNestedDeclaration(recipe.otherwise, `${label}.recipe.otherwise`, { depth }),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return validateStructuredJsonRecipe({
|
|
405
|
+
recipe,
|
|
406
|
+
label,
|
|
407
|
+
depth,
|
|
408
|
+
maximumPolicyDepth: MAXIMUM_POLICY_DEPTH,
|
|
409
|
+
validateDeclaration: validateNestedDeclaration,
|
|
410
|
+
validatePseudonymRecipe: (nestedRecipe, nestedLabel, nestedDepth) => validateRecipe("PSEUDONYMIZE", nestedRecipe, nestedLabel, nestedDepth),
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
export const validateExecutablePrivacyPolicy = (policy) => {
|
|
414
|
+
if (!isObject(policy))
|
|
415
|
+
throw new Error("Privacy policy must be an object.");
|
|
416
|
+
knownKeys(policy, [
|
|
417
|
+
"policyVersion",
|
|
418
|
+
"draft",
|
|
419
|
+
"migrationCutoff",
|
|
420
|
+
"bindings",
|
|
421
|
+
"pathMappings",
|
|
422
|
+
"tables",
|
|
423
|
+
], "policy");
|
|
424
|
+
if (policy.policyVersion !== 2) {
|
|
425
|
+
throw new Error("Executable privacy policies must use policyVersion 2.");
|
|
426
|
+
}
|
|
427
|
+
if (policy.draft === true) {
|
|
428
|
+
throw new Error("The privacy policy is still a REVIEW REQUIRED draft.");
|
|
429
|
+
}
|
|
430
|
+
if (typeof policy.migrationCutoff !== "string" ||
|
|
431
|
+
!/^\d{14}$/u.test(policy.migrationCutoff)) {
|
|
432
|
+
throw new Error("Privacy policy migrationCutoff must be a timestamp version.");
|
|
433
|
+
}
|
|
434
|
+
if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
|
|
435
|
+
throw new Error("Privacy policy must classify at least one table.");
|
|
436
|
+
}
|
|
437
|
+
if (policy.bindings !== undefined && !isObject(policy.bindings)) {
|
|
438
|
+
throw new Error("policy.bindings must be an object.");
|
|
439
|
+
}
|
|
440
|
+
const bindings = Object.fromEntries(Object.entries(policy.bindings ?? {}).map(([name, binding]) => {
|
|
441
|
+
if (!BINDING_NAME.test(name) || !isObject(binding)) {
|
|
442
|
+
throw new Error("policy.bindings contains an invalid binding.");
|
|
443
|
+
}
|
|
444
|
+
knownKeys(binding, ["environmentVariable", "approvedValueSha256"], `policy.bindings.${name}`);
|
|
445
|
+
if (typeof binding.environmentVariable !== "string" ||
|
|
446
|
+
!ENVIRONMENT_KEY.test(binding.environmentVariable)) {
|
|
447
|
+
throw new Error(`policy.bindings.${name}.environmentVariable is invalid.`);
|
|
448
|
+
}
|
|
449
|
+
if (typeof binding.approvedValueSha256 !== "string" ||
|
|
450
|
+
!HEX_64.test(binding.approvedValueSha256)) {
|
|
451
|
+
throw new Error(`policy.bindings.${name}.approvedValueSha256 must be a lowercase SHA-256 receipt.`);
|
|
452
|
+
}
|
|
453
|
+
return [
|
|
454
|
+
name,
|
|
455
|
+
Object.freeze({
|
|
456
|
+
environmentVariable: binding.environmentVariable,
|
|
457
|
+
approvedValueSha256: binding.approvedValueSha256,
|
|
458
|
+
}),
|
|
459
|
+
];
|
|
460
|
+
}));
|
|
461
|
+
if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
|
|
462
|
+
throw new Error("policy.pathMappings must be an object.");
|
|
463
|
+
}
|
|
464
|
+
const pathMappings = Object.fromEntries(Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
|
|
465
|
+
const label = `policy.pathMappings.${name}`;
|
|
466
|
+
if (!BINDING_NAME.test(name) || !isObject(mapping)) {
|
|
467
|
+
throw new Error("policy.pathMappings contains an invalid mapping.");
|
|
468
|
+
}
|
|
469
|
+
knownKeys(mapping, [
|
|
470
|
+
"binding",
|
|
471
|
+
"format",
|
|
472
|
+
"namespace",
|
|
473
|
+
"maxLength",
|
|
474
|
+
"maximumBytes",
|
|
475
|
+
"maximumSegments",
|
|
476
|
+
], label);
|
|
477
|
+
if (typeof mapping.binding !== "string" ||
|
|
478
|
+
!Object.hasOwn(bindings, mapping.binding)) {
|
|
479
|
+
throw new Error(`${label}.binding references an unknown binding.`);
|
|
480
|
+
}
|
|
481
|
+
if (typeof mapping.format !== "string" ||
|
|
482
|
+
!["uuid", "text"].includes(mapping.format)) {
|
|
483
|
+
throw new Error(`${label}.format must be uuid or text.`);
|
|
484
|
+
}
|
|
485
|
+
const normalized = {
|
|
486
|
+
binding: mapping.binding,
|
|
487
|
+
format: mapping.format,
|
|
488
|
+
namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
|
|
489
|
+
maximumBytes: positiveInteger(mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES, `${label}.maximumBytes`, 16_384),
|
|
490
|
+
maximumSegments: positiveInteger(mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS, `${label}.maximumSegments`, 256),
|
|
491
|
+
};
|
|
492
|
+
if (mapping.format === "text") {
|
|
493
|
+
return [
|
|
494
|
+
name,
|
|
495
|
+
Object.freeze({
|
|
496
|
+
...normalized,
|
|
497
|
+
maxLength: positiveInteger(mapping.maxLength, `${label}.maxLength`, 1_024),
|
|
498
|
+
}),
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
else if (mapping.maxLength !== undefined) {
|
|
502
|
+
throw new Error(`${label}.maxLength is supported only for text.`);
|
|
503
|
+
}
|
|
504
|
+
return [name, Object.freeze(normalized)];
|
|
505
|
+
}));
|
|
506
|
+
const names = new Set();
|
|
507
|
+
const tables = policy.tables.map((table, tableIndex) => {
|
|
508
|
+
const label = `policy.tables[${tableIndex}]`;
|
|
509
|
+
if (!isObject(table))
|
|
510
|
+
throw new Error(`${label} must be an object.`);
|
|
511
|
+
knownKeys(table, ["schema", "name", "sourceRows", "ownerBinding", "columns"], label);
|
|
512
|
+
const schema = identifier(table.schema ?? "public", `${label}.schema`);
|
|
513
|
+
const name = identifier(table.name, `${label}.name`);
|
|
514
|
+
const key = schema === "public" ? name : `${schema}.${name}`;
|
|
515
|
+
if (names.has(key))
|
|
516
|
+
throw new Error(`Privacy policy duplicates table ${key}.`);
|
|
517
|
+
names.add(key);
|
|
518
|
+
if (typeof table.sourceRows !== "string" ||
|
|
519
|
+
!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
|
|
520
|
+
throw new Error(`${label}.sourceRows is unsupported.`);
|
|
521
|
+
}
|
|
522
|
+
if (!Array.isArray(table.columns) || table.columns.length === 0) {
|
|
523
|
+
throw new Error(`${label}.columns must not be empty.`);
|
|
524
|
+
}
|
|
525
|
+
const sourceRows = table.sourceRows;
|
|
526
|
+
const columnNames = new Set();
|
|
527
|
+
const columns = table.columns.map((column, columnIndex) => {
|
|
528
|
+
const columnLabel = `${label}.columns[${columnIndex}]`;
|
|
529
|
+
if (!isObject(column))
|
|
530
|
+
throw new Error(`${columnLabel} must be an object.`);
|
|
531
|
+
knownKeys(column, ["name", "action", "recipe", "generated", "identity", "foreignKey"], columnLabel);
|
|
532
|
+
const columnName = identifier(column.name, `${columnLabel}.name`);
|
|
533
|
+
if (columnNames.has(columnName)) {
|
|
534
|
+
throw new Error(`Privacy policy duplicates column ${name}.${columnName}.`);
|
|
535
|
+
}
|
|
536
|
+
columnNames.add(columnName);
|
|
537
|
+
const action = String(column.action ?? "").toUpperCase();
|
|
538
|
+
if (!ACTIONS.has(action))
|
|
539
|
+
throw new Error(`${columnLabel}.action is unsupported.`);
|
|
540
|
+
if (typeof column.generated !== "string" ||
|
|
541
|
+
!["ALWAYS", "NEVER"].includes(column.generated)) {
|
|
542
|
+
throw new Error(`${columnLabel}.generated must be ALWAYS or NEVER.`);
|
|
543
|
+
}
|
|
544
|
+
if (typeof column.identity !== "string" ||
|
|
545
|
+
!["YES", "NO"].includes(column.identity)) {
|
|
546
|
+
throw new Error(`${columnLabel}.identity must be YES or NO.`);
|
|
547
|
+
}
|
|
548
|
+
if (column.foreignKey !== null) {
|
|
549
|
+
if (!isObject(column.foreignKey)) {
|
|
550
|
+
throw new Error(`${columnLabel}.foreignKey must be null or an object.`);
|
|
551
|
+
}
|
|
552
|
+
knownKeys(column.foreignKey, ["schema", "table", "column"], `${columnLabel}.foreignKey`);
|
|
553
|
+
identifier(column.foreignKey.schema, `${columnLabel}.foreignKey.schema`);
|
|
554
|
+
identifier(column.foreignKey.table, `${columnLabel}.foreignKey.table`);
|
|
555
|
+
identifier(column.foreignKey.column, `${columnLabel}.foreignKey.column`);
|
|
556
|
+
}
|
|
557
|
+
return Object.freeze({
|
|
558
|
+
...column,
|
|
559
|
+
name: columnName,
|
|
560
|
+
action: action,
|
|
561
|
+
recipe: validateRecipe(action, column.recipe, columnLabel),
|
|
562
|
+
generated: column.generated,
|
|
563
|
+
identity: column.identity,
|
|
564
|
+
foreignKey: column.foreignKey,
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
let ownerBinding = null;
|
|
568
|
+
if (table.ownerBinding !== undefined && table.ownerBinding !== null) {
|
|
569
|
+
if (!isObject(table.ownerBinding)) {
|
|
570
|
+
throw new Error(`${label}.ownerBinding must be an object.`);
|
|
571
|
+
}
|
|
572
|
+
knownKeys(table.ownerBinding, ["binding", "column", "columns", "match", "nullBehavior"], `${label}.ownerBinding`);
|
|
573
|
+
const binding = nonEmpty(table.ownerBinding.binding, `${label}.ownerBinding.binding`);
|
|
574
|
+
const hasColumn = table.ownerBinding.column !== undefined;
|
|
575
|
+
const hasColumns = table.ownerBinding.columns !== undefined;
|
|
576
|
+
if (hasColumn === hasColumns) {
|
|
577
|
+
throw new Error(`${label}.ownerBinding must declare exactly one of column or columns.`);
|
|
578
|
+
}
|
|
579
|
+
let ownerColumns;
|
|
580
|
+
if (hasColumn) {
|
|
581
|
+
if (table.ownerBinding.match !== undefined) {
|
|
582
|
+
throw new Error(`${label}.ownerBinding.match is supported only with columns.`);
|
|
583
|
+
}
|
|
584
|
+
ownerColumns = [
|
|
585
|
+
identifier(table.ownerBinding.column, `${label}.ownerBinding.column`),
|
|
586
|
+
];
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
if (!Array.isArray(table.ownerBinding.columns) ||
|
|
590
|
+
table.ownerBinding.columns.length === 0 ||
|
|
591
|
+
table.ownerBinding.columns.length > 16) {
|
|
592
|
+
throw new Error(`${label}.ownerBinding.columns must contain 1 through 16 columns.`);
|
|
593
|
+
}
|
|
594
|
+
if (table.ownerBinding.match !== "any") {
|
|
595
|
+
throw new Error(`${label}.ownerBinding.match must be any when columns are used.`);
|
|
596
|
+
}
|
|
597
|
+
ownerColumns = table.ownerBinding.columns.map((column, index) => identifier(column, `${label}.ownerBinding.columns[${index}]`));
|
|
598
|
+
if (new Set(ownerColumns).size !== ownerColumns.length) {
|
|
599
|
+
throw new Error(`${label}.ownerBinding.columns must not contain duplicates.`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (table.ownerBinding.nullBehavior !== undefined &&
|
|
603
|
+
table.ownerBinding.nullBehavior !== "otherwise") {
|
|
604
|
+
throw new Error(`${label}.ownerBinding.nullBehavior must be otherwise when declared.`);
|
|
605
|
+
}
|
|
606
|
+
if (!Object.hasOwn(bindings, binding)) {
|
|
607
|
+
throw new Error(`${label}.ownerBinding references an unknown binding.`);
|
|
608
|
+
}
|
|
609
|
+
for (const column of ownerColumns) {
|
|
610
|
+
if (!columnNames.has(column)) {
|
|
611
|
+
throw new Error(`${label}.ownerBinding references an unknown column.`);
|
|
612
|
+
}
|
|
613
|
+
const ownerColumn = columns.find((entry) => entry.name === column);
|
|
614
|
+
if (!ownerColumn || ownerColumn.action !== "PSEUDONYMIZE") {
|
|
615
|
+
throw new Error(`${label}.ownerBinding columns must be explicitly pseudonymized.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
ownerBinding = Object.freeze({
|
|
619
|
+
binding,
|
|
620
|
+
columns: Object.freeze(ownerColumns),
|
|
621
|
+
match: "any",
|
|
622
|
+
...(table.ownerBinding.nullBehavior === undefined
|
|
623
|
+
? {}
|
|
624
|
+
: { nullBehavior: table.ownerBinding.nullBehavior }),
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const usesApprovedOwner = (declaration) => (declaration.recipe !== null &&
|
|
628
|
+
"kind" in declaration.recipe &&
|
|
629
|
+
declaration.recipe.kind === "approved-owner") ||
|
|
630
|
+
structuredJsonDeclarations(declaration.recipe).some(usesApprovedOwner);
|
|
631
|
+
if (columns.some(usesApprovedOwner) && !ownerBinding) {
|
|
632
|
+
throw new Error(`${label} uses approved-owner without ownerBinding.`);
|
|
633
|
+
}
|
|
634
|
+
const visitRecipes = (declaration, visit) => {
|
|
635
|
+
visit(declaration.recipe);
|
|
636
|
+
if (declaration.recipe !== null &&
|
|
637
|
+
"kind" in declaration.recipe &&
|
|
638
|
+
declaration.recipe.kind === "approved-owner") {
|
|
639
|
+
visitRecipes(declaration.recipe.approved, visit);
|
|
640
|
+
visitRecipes(declaration.recipe.otherwise, visit);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
structuredJsonDeclarations(declaration.recipe).forEach((nested) => visitRecipes(nested, visit));
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
for (const column of columns) {
|
|
647
|
+
visitRecipes(column, (recipe) => {
|
|
648
|
+
if (recipe &&
|
|
649
|
+
"kind" in recipe &&
|
|
650
|
+
recipe.kind === "path-map" &&
|
|
651
|
+
!Object.hasOwn(pathMappings, recipe.mapping)) {
|
|
652
|
+
throw new Error(`${label} references unknown path mapping ${recipe.mapping}.`);
|
|
653
|
+
}
|
|
654
|
+
if (recipe !== null &&
|
|
655
|
+
"kind" in recipe &&
|
|
656
|
+
recipe.kind === "binding-substitute" &&
|
|
657
|
+
!Object.hasOwn(bindings, recipe.binding)) {
|
|
658
|
+
throw new Error(`${label} references unknown privacy binding ${recipe.binding}.`);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
const dependencies = new Map(columns.map((column) => [column.name, []]));
|
|
663
|
+
const visitDigestInputs = (declaration) => {
|
|
664
|
+
if (declaration.recipe !== null &&
|
|
665
|
+
"kind" in declaration.recipe &&
|
|
666
|
+
declaration.recipe.kind === "digest") {
|
|
667
|
+
return [...declaration.recipe.inputs];
|
|
668
|
+
}
|
|
669
|
+
if (declaration.recipe !== null &&
|
|
670
|
+
"kind" in declaration.recipe &&
|
|
671
|
+
declaration.recipe.kind === "approved-owner") {
|
|
672
|
+
return [
|
|
673
|
+
...visitDigestInputs(declaration.recipe.approved),
|
|
674
|
+
...visitDigestInputs(declaration.recipe.otherwise),
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
return structuredJsonDeclarations(declaration.recipe).flatMap(visitDigestInputs);
|
|
678
|
+
};
|
|
679
|
+
for (const column of columns) {
|
|
680
|
+
const inputs = [...new Set(visitDigestInputs(column))];
|
|
681
|
+
for (const input of inputs) {
|
|
682
|
+
const inputColumn = columns.find((entry) => entry.name === input);
|
|
683
|
+
if (!inputColumn) {
|
|
684
|
+
throw new Error(`${label}.${column.name} digest references unknown column ${input}.`);
|
|
685
|
+
}
|
|
686
|
+
if (inputColumn.action === "EXCLUDE") {
|
|
687
|
+
throw new Error(`${label}.${column.name} digest input ${input} is excluded instead of sanitized.`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
dependencies.set(column.name, inputs);
|
|
691
|
+
}
|
|
692
|
+
const resolvedDependencies = new Set();
|
|
693
|
+
const resolvingDependencies = new Set();
|
|
694
|
+
const visitDependency = (columnName) => {
|
|
695
|
+
if (resolvedDependencies.has(columnName))
|
|
696
|
+
return;
|
|
697
|
+
if (resolvingDependencies.has(columnName)) {
|
|
698
|
+
throw new Error(`${label} contains a cyclic digest dependency.`);
|
|
699
|
+
}
|
|
700
|
+
resolvingDependencies.add(columnName);
|
|
701
|
+
for (const input of dependencies.get(columnName) ?? []) {
|
|
702
|
+
visitDependency(input);
|
|
703
|
+
}
|
|
704
|
+
resolvingDependencies.delete(columnName);
|
|
705
|
+
resolvedDependencies.add(columnName);
|
|
706
|
+
};
|
|
707
|
+
columns.forEach((column) => visitDependency(column.name));
|
|
708
|
+
return Object.freeze({
|
|
709
|
+
schema,
|
|
710
|
+
name,
|
|
711
|
+
sourceRows,
|
|
712
|
+
ownerBinding,
|
|
713
|
+
columns: Object.freeze(columns),
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
const dateShiftGroups = new Map();
|
|
717
|
+
const visitDateShiftGroups = (declaration) => {
|
|
718
|
+
const recipe = declaration.recipe;
|
|
719
|
+
if (!recipe || !("kind" in recipe))
|
|
720
|
+
return;
|
|
721
|
+
if (recipe.kind === "date-shift" && recipe.group) {
|
|
722
|
+
const existing = dateShiftGroups.get(recipe.group);
|
|
723
|
+
if (existing !== undefined && existing !== recipe.days) {
|
|
724
|
+
throw new Error(`Privacy date-shift group ${recipe.group} must use one days value.`);
|
|
725
|
+
}
|
|
726
|
+
dateShiftGroups.set(recipe.group, recipe.days);
|
|
727
|
+
}
|
|
728
|
+
if (recipe.kind === "approved-owner") {
|
|
729
|
+
visitDateShiftGroups(recipe.approved);
|
|
730
|
+
visitDateShiftGroups(recipe.otherwise);
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
structuredJsonDeclarations(recipe).forEach(visitDateShiftGroups);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
|
|
737
|
+
return Object.freeze({
|
|
738
|
+
policyVersion: 2,
|
|
739
|
+
migrationCutoff: policy.migrationCutoff,
|
|
740
|
+
bindings: Object.freeze(bindings),
|
|
741
|
+
pathMappings: Object.freeze(pathMappings),
|
|
742
|
+
tables: Object.freeze(tables),
|
|
743
|
+
});
|
|
744
|
+
};
|
|
745
|
+
const digest = (key, namespace, value) => {
|
|
746
|
+
const serialized = JSON.stringify(value);
|
|
747
|
+
if (serialized === undefined) {
|
|
748
|
+
throw new Error("Privacy input must be JSON-compatible.");
|
|
749
|
+
}
|
|
750
|
+
return createHmac("sha256", key)
|
|
751
|
+
.update(namespace)
|
|
752
|
+
.update("\0")
|
|
753
|
+
.update(serialized)
|
|
754
|
+
.digest();
|
|
755
|
+
};
|
|
756
|
+
const gtinCheckDigit = (payload) => {
|
|
757
|
+
const sum = [...payload]
|
|
758
|
+
.reverse()
|
|
759
|
+
.reduce((total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 3 : 1), 0);
|
|
760
|
+
return String((10 - (sum % 10)) % 10);
|
|
761
|
+
};
|
|
762
|
+
const isValidGtin = (value, length) => typeof value === "string" &&
|
|
763
|
+
value.length === length &&
|
|
764
|
+
/^\d+$/u.test(value) &&
|
|
765
|
+
value.at(-1) === gtinCheckDigit(value.slice(0, -1));
|
|
766
|
+
const pseudonym = (key, recipe, value) => {
|
|
767
|
+
if (value === null)
|
|
768
|
+
return null;
|
|
769
|
+
const bytes = digest(key, recipe.namespace, value);
|
|
770
|
+
if (recipe.format === "uuid") {
|
|
771
|
+
const copy = Buffer.from(bytes.subarray(0, 16));
|
|
772
|
+
copy[6] = (copy[6] & 0x0f) | 0x40;
|
|
773
|
+
copy[8] = (copy[8] & 0x3f) | 0x80;
|
|
774
|
+
const hex = copy.toString("hex");
|
|
775
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
776
|
+
}
|
|
777
|
+
if (recipe.format === "email") {
|
|
778
|
+
return `rehearsal+${bytes.toString("hex").slice(0, 24)}@example.invalid`;
|
|
779
|
+
}
|
|
780
|
+
if (recipe.format === "integer") {
|
|
781
|
+
return Number(bytes.readBigUInt64BE(0) % 9007199254740991n) + 1;
|
|
782
|
+
}
|
|
783
|
+
if (recipe.format === "hex") {
|
|
784
|
+
return bytes.toString("hex").slice(0, recipe.length);
|
|
785
|
+
}
|
|
786
|
+
if (recipe.format === "gtin") {
|
|
787
|
+
const allowedLengths = recipe.allowedLengths ??
|
|
788
|
+
(recipe.length === undefined ? [] : [recipe.length]);
|
|
789
|
+
const sourceLength = typeof value === "string" ? value.length : null;
|
|
790
|
+
if (sourceLength === null ||
|
|
791
|
+
!allowedLengths.includes(sourceLength) ||
|
|
792
|
+
!isValidGtin(value, sourceLength)) {
|
|
793
|
+
if (recipe.length !== undefined) {
|
|
794
|
+
throw new Error(`A gtin pseudonym source must be a valid normalized GTIN-${recipe.length} string.`);
|
|
795
|
+
}
|
|
796
|
+
throw new Error(`A gtin pseudonym source must be a valid normalized GTIN string with an allowed length (${allowedLengths.join(", ")}).`);
|
|
797
|
+
}
|
|
798
|
+
const modulus = 10n ** BigInt(sourceLength - 1);
|
|
799
|
+
const payload = (BigInt(`0x${bytes.toString("hex")}`) % modulus)
|
|
800
|
+
.toString()
|
|
801
|
+
.padStart(sourceLength - 1, "0");
|
|
802
|
+
return `${payload}${gtinCheckDigit(payload)}`;
|
|
803
|
+
}
|
|
804
|
+
if (recipe.format === "url") {
|
|
805
|
+
if (typeof value !== "string" ||
|
|
806
|
+
value.trim() !== value ||
|
|
807
|
+
/\s/u.test(value)) {
|
|
808
|
+
throw new Error("A url pseudonym source must be a valid HTTPS URL.");
|
|
809
|
+
}
|
|
810
|
+
let source;
|
|
811
|
+
try {
|
|
812
|
+
source = new URL(value);
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
throw new Error("A url pseudonym source must be a valid HTTPS URL.");
|
|
816
|
+
}
|
|
817
|
+
if (source.protocol !== "https:") {
|
|
818
|
+
throw new Error("A url pseudonym source must be a valid HTTPS URL.");
|
|
819
|
+
}
|
|
820
|
+
const output = `${recipe.origin}${URL_PATH_PREFIX}${bytes
|
|
821
|
+
.toString("hex")
|
|
822
|
+
.slice(0, URL_TOKEN_LENGTH)}`;
|
|
823
|
+
if (output.length > recipe.maxLength) {
|
|
824
|
+
throw new Error("A url pseudonym exceeded its reviewed maximum length.");
|
|
825
|
+
}
|
|
826
|
+
return output;
|
|
827
|
+
}
|
|
828
|
+
const token = `rehearsal_${bytes.toString("hex")}`;
|
|
829
|
+
return token.slice(0, recipe.maxLength);
|
|
830
|
+
};
|
|
831
|
+
const remapPath = ({ key, mapping, bindingValue, value, label, }) => {
|
|
832
|
+
if (typeof value !== "string") {
|
|
833
|
+
throw new Error(`${label} must be a Storage-style path string.`);
|
|
834
|
+
}
|
|
835
|
+
if (Buffer.byteLength(value) > mapping.maximumBytes) {
|
|
836
|
+
throw new Error(`${label} exceeds its reviewed byte limit.`);
|
|
837
|
+
}
|
|
838
|
+
const segments = value.split("/");
|
|
839
|
+
if (!value ||
|
|
840
|
+
value.startsWith("/") ||
|
|
841
|
+
value.endsWith("/") ||
|
|
842
|
+
segments.length > mapping.maximumSegments ||
|
|
843
|
+
segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
844
|
+
throw new Error(`${label} is not a safe bounded Storage path.`);
|
|
845
|
+
}
|
|
846
|
+
if (typeof bindingValue !== "string" ||
|
|
847
|
+
!bindingValue ||
|
|
848
|
+
bindingValue.includes("/") ||
|
|
849
|
+
bindingValue === "." ||
|
|
850
|
+
bindingValue === "..") {
|
|
851
|
+
throw new Error(`${label} has an invalid reviewed identity binding.`);
|
|
852
|
+
}
|
|
853
|
+
if (segments[0] !== bindingValue) {
|
|
854
|
+
throw new Error(`${label} does not begin with its exact reviewed identity.`);
|
|
855
|
+
}
|
|
856
|
+
const destination = [
|
|
857
|
+
String(pseudonym(key, mapping, bindingValue)),
|
|
858
|
+
...segments.slice(1),
|
|
859
|
+
].join("/");
|
|
860
|
+
if (Buffer.byteLength(destination) > mapping.maximumBytes) {
|
|
861
|
+
throw new Error(`${label} destination exceeds its reviewed byte limit.`);
|
|
862
|
+
}
|
|
863
|
+
return destination;
|
|
864
|
+
};
|
|
865
|
+
const substituteBinding = ({ key, recipe, bindingValue, value, label, }) => {
|
|
866
|
+
if (typeof bindingValue !== "string" || !UUID.test(bindingValue)) {
|
|
867
|
+
throw new Error(`${label} has an invalid reviewed UUID binding.`);
|
|
868
|
+
}
|
|
869
|
+
if (value === null)
|
|
870
|
+
return null;
|
|
871
|
+
if (typeof value !== "string") {
|
|
872
|
+
throw new Error(`${label} must be text or null for binding substitution.`);
|
|
873
|
+
}
|
|
874
|
+
if (Buffer.byteLength(value) > recipe.maximumBytes) {
|
|
875
|
+
throw new Error(`${label} exceeds its reviewed byte limit.`);
|
|
876
|
+
}
|
|
877
|
+
const replacement = String(pseudonym(key, recipe, bindingValue));
|
|
878
|
+
const destination = value.replaceAll(bindingValue, replacement);
|
|
879
|
+
if (Buffer.byteLength(destination) > recipe.maximumBytes) {
|
|
880
|
+
throw new Error(`${label} destination exceeds its reviewed byte limit.`);
|
|
881
|
+
}
|
|
882
|
+
return destination;
|
|
883
|
+
};
|
|
884
|
+
const executeDeclaration = ({ declaration, value, key, path, ownerApproved, pathMappings, bindingValues, resolveInput, }) => {
|
|
885
|
+
if (declaration.action === "KEEP")
|
|
886
|
+
return value;
|
|
887
|
+
if (declaration.action === "EXCLUDE")
|
|
888
|
+
return undefined;
|
|
889
|
+
if (declaration.action === "REPLACE")
|
|
890
|
+
return structuredClone(declaration.recipe.value);
|
|
891
|
+
if (declaration.action === "PSEUDONYMIZE") {
|
|
892
|
+
return pseudonym(key, declaration.recipe, value);
|
|
893
|
+
}
|
|
894
|
+
const recipe = declaration.recipe;
|
|
895
|
+
if (!recipe || !("kind" in recipe) || recipe.kind === "constant") {
|
|
896
|
+
throw new Error(`${path} has an invalid derivation recipe.`);
|
|
897
|
+
}
|
|
898
|
+
if (recipe.kind === "date-shift") {
|
|
899
|
+
if (value === null)
|
|
900
|
+
return null;
|
|
901
|
+
if (recipe.representation === "epoch-milliseconds" &&
|
|
902
|
+
(typeof value !== "number" || !Number.isSafeInteger(value))) {
|
|
903
|
+
throw new Error(`${path} must be a safe epoch-millisecond integer.`);
|
|
904
|
+
}
|
|
905
|
+
const date = new Date(value);
|
|
906
|
+
if (!Number.isFinite(date.valueOf()))
|
|
907
|
+
throw new Error(`${path} is not a valid date.`);
|
|
908
|
+
const direction = recipe.group
|
|
909
|
+
? digest(key, "date-shift-group", recipe.group)[0] % 2 === 0
|
|
910
|
+
? -1
|
|
911
|
+
: 1
|
|
912
|
+
: digest(key, path, value)[0] % 2 === 0
|
|
913
|
+
? -1
|
|
914
|
+
: 1;
|
|
915
|
+
date.setUTCDate(date.getUTCDate() + direction * recipe.days);
|
|
916
|
+
if (!Number.isFinite(date.valueOf())) {
|
|
917
|
+
throw new Error(`${path} shifts outside the supported date range.`);
|
|
918
|
+
}
|
|
919
|
+
return recipe.representation === "epoch-milliseconds"
|
|
920
|
+
? date.valueOf()
|
|
921
|
+
: date.toISOString();
|
|
922
|
+
}
|
|
923
|
+
if (recipe.kind === "enum") {
|
|
924
|
+
if (value === null && recipe.allowNull)
|
|
925
|
+
return null;
|
|
926
|
+
if (!recipe.values.some((candidate) => Object.is(candidate, value))) {
|
|
927
|
+
throw new Error(`${path} is outside its reviewed scalar domain.`);
|
|
928
|
+
}
|
|
929
|
+
return value;
|
|
930
|
+
}
|
|
931
|
+
if (recipe.kind === "validated-string") {
|
|
932
|
+
if (value === null && recipe.allowNull)
|
|
933
|
+
return null;
|
|
934
|
+
if (typeof value !== "string" ||
|
|
935
|
+
Buffer.byteLength(value) > recipe.maximumBytes ||
|
|
936
|
+
!PORTABLE_CODE.test(value)) {
|
|
937
|
+
throw new Error(`${path} is not a valid reviewed portable code.`);
|
|
938
|
+
}
|
|
939
|
+
return value;
|
|
940
|
+
}
|
|
941
|
+
if (recipe.kind === "digest") {
|
|
942
|
+
const inputs = recipe.inputs.map((name) => {
|
|
943
|
+
const sanitized = resolveInput(name);
|
|
944
|
+
if (sanitized === undefined) {
|
|
945
|
+
throw new Error(`${path} digest input ${name} was not sanitized.`);
|
|
946
|
+
}
|
|
947
|
+
return { name, value: sanitized };
|
|
948
|
+
});
|
|
949
|
+
return digest(key, recipe.namespace, inputs)
|
|
950
|
+
.toString("hex")
|
|
951
|
+
.slice(0, recipe.length);
|
|
952
|
+
}
|
|
953
|
+
if (recipe.kind === "path-map") {
|
|
954
|
+
const mapping = pathMappings[recipe.mapping];
|
|
955
|
+
if (!mapping) {
|
|
956
|
+
throw new Error(`${path} references an unknown privacy path mapping.`);
|
|
957
|
+
}
|
|
958
|
+
return remapPath({
|
|
959
|
+
key,
|
|
960
|
+
mapping,
|
|
961
|
+
bindingValue: bindingValues.get(mapping.binding),
|
|
962
|
+
value,
|
|
963
|
+
label: path,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
if (recipe.kind === "binding-substitute") {
|
|
967
|
+
return substituteBinding({
|
|
968
|
+
key,
|
|
969
|
+
recipe,
|
|
970
|
+
bindingValue: bindingValues.get(recipe.binding),
|
|
971
|
+
value,
|
|
972
|
+
label: path,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
if (recipe.kind === "approved-owner") {
|
|
976
|
+
if (ownerApproved === undefined) {
|
|
977
|
+
throw new Error(`${path} has no reviewed owner condition.`);
|
|
978
|
+
}
|
|
979
|
+
return executeDeclaration({
|
|
980
|
+
declaration: ownerApproved ? recipe.approved : recipe.otherwise,
|
|
981
|
+
value,
|
|
982
|
+
key,
|
|
983
|
+
path,
|
|
984
|
+
ownerApproved,
|
|
985
|
+
pathMappings,
|
|
986
|
+
bindingValues,
|
|
987
|
+
resolveInput,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
if (!STRUCTURED_JSON_KINDS.has(recipe.kind)) {
|
|
991
|
+
throw new Error(`${path} uses an unsupported privacy recipe.`);
|
|
992
|
+
}
|
|
993
|
+
return executeStructuredJsonDeclaration({
|
|
994
|
+
declaration,
|
|
995
|
+
value,
|
|
996
|
+
key,
|
|
997
|
+
path,
|
|
998
|
+
pseudonymize: (nestedKey, nestedRecipe, nestedValue) => pseudonym(nestedKey, nestedRecipe, nestedValue),
|
|
999
|
+
execute: (nestedDeclaration, nestedValue, nestedPath) => executeDeclaration({
|
|
1000
|
+
declaration: nestedDeclaration,
|
|
1001
|
+
value: nestedValue,
|
|
1002
|
+
key,
|
|
1003
|
+
path: nestedPath,
|
|
1004
|
+
ownerApproved,
|
|
1005
|
+
pathMappings,
|
|
1006
|
+
bindingValues,
|
|
1007
|
+
resolveInput,
|
|
1008
|
+
}),
|
|
1009
|
+
});
|
|
1010
|
+
};
|
|
1011
|
+
export const createPrivacyEngine = ({ policy, key, environment = process.env, }) => {
|
|
1012
|
+
const normalized = validateExecutablePrivacyPolicy(policy);
|
|
1013
|
+
const secret = Buffer.isBuffer(key) ? key : Buffer.from(key ?? "");
|
|
1014
|
+
if (secret.length < KEY_BYTES) {
|
|
1015
|
+
throw new Error(`Privacy key must contain at least ${KEY_BYTES} bytes.`);
|
|
1016
|
+
}
|
|
1017
|
+
const bindingValues = new Map(Object.entries(normalized.bindings).map(([name, binding]) => {
|
|
1018
|
+
const value = environment[binding.environmentVariable];
|
|
1019
|
+
if (typeof value !== "string" || !value) {
|
|
1020
|
+
throw new Error(`Privacy binding ${name} requires ${binding.environmentVariable}.`);
|
|
1021
|
+
}
|
|
1022
|
+
const receipt = createHash("sha256").update(value).digest("hex");
|
|
1023
|
+
if (receipt !== binding.approvedValueSha256) {
|
|
1024
|
+
throw new Error(`Privacy binding ${name} does not match its review receipt.`);
|
|
1025
|
+
}
|
|
1026
|
+
return [name, value];
|
|
1027
|
+
}));
|
|
1028
|
+
const tables = new Map(normalized.tables.map((table) => [
|
|
1029
|
+
table.schema === "public" ? table.name : `${table.schema}.${table.name}`,
|
|
1030
|
+
table,
|
|
1031
|
+
]));
|
|
1032
|
+
return Object.freeze({
|
|
1033
|
+
keyFingerprint: createHash("sha256").update(secret).digest("hex"),
|
|
1034
|
+
remapPath({ mapping: mappingName, value, }) {
|
|
1035
|
+
const mapping = normalized.pathMappings[mappingName];
|
|
1036
|
+
if (!mapping) {
|
|
1037
|
+
throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
|
|
1038
|
+
}
|
|
1039
|
+
return remapPath({
|
|
1040
|
+
key: secret,
|
|
1041
|
+
mapping,
|
|
1042
|
+
bindingValue: bindingValues.get(mapping.binding),
|
|
1043
|
+
value,
|
|
1044
|
+
label: `Privacy path mapping ${mappingName}`,
|
|
1045
|
+
});
|
|
1046
|
+
},
|
|
1047
|
+
sanitize(record) {
|
|
1048
|
+
if (!isObject(record) ||
|
|
1049
|
+
typeof (record.schema ?? "public") !== "string" ||
|
|
1050
|
+
!IDENTIFIER.test(String(record.schema ?? "public")) ||
|
|
1051
|
+
typeof record.table !== "string" ||
|
|
1052
|
+
!IDENTIFIER.test(record.table) ||
|
|
1053
|
+
!isObject(record.row)) {
|
|
1054
|
+
throw new Error("Source record has an invalid privacy-engine shape.");
|
|
1055
|
+
}
|
|
1056
|
+
const schema = String(record.schema ?? "public");
|
|
1057
|
+
const rowSource = record.row;
|
|
1058
|
+
const relation = schema === "public" ? record.table : `${schema}.${record.table}`;
|
|
1059
|
+
const table = tables.get(relation);
|
|
1060
|
+
if (!table)
|
|
1061
|
+
throw new Error(`Privacy policy does not classify table ${relation}.`);
|
|
1062
|
+
if (table.sourceRows === "EXCLUDE")
|
|
1063
|
+
return null;
|
|
1064
|
+
const actual = Object.keys(rowSource).sort();
|
|
1065
|
+
const expected = table.columns.map((column) => column.name).sort();
|
|
1066
|
+
if (actual.join("\0") !== expected.join("\0")) {
|
|
1067
|
+
throw new Error(`Source table ${relation} contains an unclassified or missing column.`);
|
|
1068
|
+
}
|
|
1069
|
+
let ownerApproved;
|
|
1070
|
+
if (table.ownerBinding) {
|
|
1071
|
+
const approvedValue = bindingValues.get(table.ownerBinding.binding);
|
|
1072
|
+
const owners = table.ownerBinding.columns.map((column) => rowSource[column]);
|
|
1073
|
+
for (const owner of owners) {
|
|
1074
|
+
if (owner === null) {
|
|
1075
|
+
if (table.ownerBinding.nullBehavior !== "otherwise") {
|
|
1076
|
+
throw new Error(`${relation} has an invalid owner binding value.`);
|
|
1077
|
+
}
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (!["string", "number"].includes(typeof owner) ||
|
|
1081
|
+
owner === "" ||
|
|
1082
|
+
(typeof owner === "number" && !Number.isFinite(owner))) {
|
|
1083
|
+
throw new Error(`${relation} has an invalid owner binding value.`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
ownerApproved = owners.some((owner) => owner !== null && String(owner) === approvedValue);
|
|
1087
|
+
}
|
|
1088
|
+
const sanitizedColumns = new Map();
|
|
1089
|
+
const resolvingColumns = new Set();
|
|
1090
|
+
const sanitizeColumn = (columnName) => {
|
|
1091
|
+
if (sanitizedColumns.has(columnName)) {
|
|
1092
|
+
return sanitizedColumns.get(columnName);
|
|
1093
|
+
}
|
|
1094
|
+
if (resolvingColumns.has(columnName)) {
|
|
1095
|
+
throw new Error(`${relation} contains a cyclic digest dependency.`);
|
|
1096
|
+
}
|
|
1097
|
+
const column = table.columns.find((entry) => entry.name === columnName);
|
|
1098
|
+
if (!column) {
|
|
1099
|
+
throw new Error(`${relation} digest references unknown column ${columnName}.`);
|
|
1100
|
+
}
|
|
1101
|
+
resolvingColumns.add(columnName);
|
|
1102
|
+
try {
|
|
1103
|
+
const transformed = executeDeclaration({
|
|
1104
|
+
declaration: column,
|
|
1105
|
+
value: rowSource[column.name],
|
|
1106
|
+
key: secret,
|
|
1107
|
+
path: `${relation}.${column.name}`,
|
|
1108
|
+
ownerApproved,
|
|
1109
|
+
pathMappings: normalized.pathMappings,
|
|
1110
|
+
bindingValues,
|
|
1111
|
+
resolveInput: sanitizeColumn,
|
|
1112
|
+
});
|
|
1113
|
+
sanitizedColumns.set(columnName, transformed);
|
|
1114
|
+
return transformed;
|
|
1115
|
+
}
|
|
1116
|
+
finally {
|
|
1117
|
+
resolvingColumns.delete(columnName);
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
const row = Object.fromEntries(table.columns.flatMap((column) => {
|
|
1121
|
+
const transformed = sanitizeColumn(column.name);
|
|
1122
|
+
return transformed === undefined ? [] : [[column.name, transformed]];
|
|
1123
|
+
}));
|
|
1124
|
+
return schema === "public"
|
|
1125
|
+
? { table: record.table, row }
|
|
1126
|
+
: { schema, table: record.table, row };
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
};
|
|
1130
|
+
export const createPrivacyKey = async (path) => {
|
|
1131
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
1132
|
+
const key = randomBytes(KEY_BYTES);
|
|
1133
|
+
const handle = await open(path, "wx", 0o600);
|
|
1134
|
+
try {
|
|
1135
|
+
await handle.writeFile(`${key.toString("base64")}\n`);
|
|
1136
|
+
await handle.sync();
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
await handle.close();
|
|
1140
|
+
}
|
|
1141
|
+
await chmod(path, 0o600);
|
|
1142
|
+
return { fingerprint: createHash("sha256").update(key).digest("hex") };
|
|
1143
|
+
};
|
|
1144
|
+
export const readPrivacyKey = async (path) => {
|
|
1145
|
+
const details = await stat(path);
|
|
1146
|
+
if (!details.isFile() || (details.mode & 0o077) !== 0) {
|
|
1147
|
+
throw new Error("Privacy key must be an owner-only regular file.");
|
|
1148
|
+
}
|
|
1149
|
+
const encoded = (await readFile(path, "utf8")).trim();
|
|
1150
|
+
const key = Buffer.from(encoded, "base64");
|
|
1151
|
+
if (key.length !== KEY_BYTES || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
|
|
1152
|
+
throw new Error("Privacy key file is invalid.");
|
|
1153
|
+
}
|
|
1154
|
+
return key;
|
|
1155
|
+
};
|
|
1156
|
+
export const assertPrivacyKeyFingerprint = (key, expected) => {
|
|
1157
|
+
if (typeof expected !== "string" || !HEX_64.test(expected))
|
|
1158
|
+
throw new Error("Privacy key fingerprint is invalid.");
|
|
1159
|
+
const actual = createHash("sha256").update(key).digest("hex");
|
|
1160
|
+
if (actual !== expected)
|
|
1161
|
+
throw new Error("Privacy key does not match the reviewed baseline receipt.");
|
|
1162
|
+
};
|