@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +3 -1
- package/docs/configuration.md +40 -5
- package/docs/getting-started.md +28 -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/package.json +1 -1
- package/scripts/install/create_root_config.mjs +5 -22
- package/src/baseline/privacy_engine.mjs +660 -213
- package/src/baseline/privacy_structured_json.mjs +554 -0
- package/src/cli/rehearsal.mjs +3 -1
- package/src/cli/renderers.mjs +12 -0
- package/src/cli/source_commands.mjs +11 -3
- package/src/identity/claim.mjs +23 -21
- package/src/project/install_scaffold.mjs +35 -1
- package/src/project/setup.mjs +9 -1
- package/src/source/access.mjs +50 -5
- package/src/source/asset_transfer.mjs +139 -87
- package/src/source/external_reader.mjs +136 -23
- package/src/source/postgresql_access.mjs +80 -23
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/** Validate and execute the bounded structured-JSON privacy recipes. */
|
|
2
|
+
|
|
3
|
+
const JSON_UNION_VARIANTS = new Set([
|
|
4
|
+
"string",
|
|
5
|
+
"number",
|
|
6
|
+
"boolean",
|
|
7
|
+
"array",
|
|
8
|
+
"object",
|
|
9
|
+
]);
|
|
10
|
+
const DICTIONARY_KEY_FORMATS = new Set([
|
|
11
|
+
"integer",
|
|
12
|
+
"positive-integer",
|
|
13
|
+
"uuid",
|
|
14
|
+
"identifier",
|
|
15
|
+
"text",
|
|
16
|
+
]);
|
|
17
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
18
|
+
const UUID =
|
|
19
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
20
|
+
const DEFAULT_MAXIMUM_BYTES = 65_536;
|
|
21
|
+
const DEFAULT_MAXIMUM_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
export const STRUCTURED_JSON_KINDS = new Set([
|
|
24
|
+
"json-object",
|
|
25
|
+
"json-array",
|
|
26
|
+
"json-union",
|
|
27
|
+
"json-dictionary",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const isObject = (value) =>
|
|
31
|
+
value !== null &&
|
|
32
|
+
typeof value === "object" &&
|
|
33
|
+
!Array.isArray(value) &&
|
|
34
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
35
|
+
|
|
36
|
+
const knownKeys = (value, allowed, label) => {
|
|
37
|
+
for (const key of Object.keys(value)) {
|
|
38
|
+
if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const positiveInteger = (value, label, maximum) => {
|
|
43
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
44
|
+
throw new Error(`${label} must be an integer from 1 through ${maximum}.`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const safeInteger = (value, label) => {
|
|
50
|
+
if (!Number.isSafeInteger(value)) {
|
|
51
|
+
throw new Error(`${label} must be a safe integer.`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const boundsFor = (recipe, label, maximumPolicyDepth) => ({
|
|
57
|
+
maximumBytes: positiveInteger(
|
|
58
|
+
recipe.maximumBytes ?? DEFAULT_MAXIMUM_BYTES,
|
|
59
|
+
`${label}.recipe.maximumBytes`,
|
|
60
|
+
16_777_216,
|
|
61
|
+
),
|
|
62
|
+
maximumDepth: positiveInteger(
|
|
63
|
+
recipe.maximumDepth ?? DEFAULT_MAXIMUM_DEPTH,
|
|
64
|
+
`${label}.recipe.maximumDepth`,
|
|
65
|
+
maximumPolicyDepth,
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const classifiesUnknownJsonValue = (declaration) => {
|
|
70
|
+
if (["REPLACE", "EXCLUDE", "PSEUDONYMIZE"].includes(declaration.action)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (declaration.action !== "DERIVE") return false;
|
|
74
|
+
if (declaration.recipe.kind === "approved-owner") {
|
|
75
|
+
return (
|
|
76
|
+
classifiesUnknownJsonValue(declaration.recipe.approved) &&
|
|
77
|
+
classifiesUnknownJsonValue(declaration.recipe.otherwise)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (declaration.recipe.kind === "json-union") return true;
|
|
81
|
+
if (declaration.recipe.kind === "json-array") {
|
|
82
|
+
return classifiesUnknownJsonValue(declaration.recipe.items);
|
|
83
|
+
}
|
|
84
|
+
if (declaration.recipe.kind === "json-object") {
|
|
85
|
+
return Object.values(declaration.recipe.fields).every(
|
|
86
|
+
classifiesUnknownJsonValue,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (declaration.recipe.kind === "json-dictionary") {
|
|
90
|
+
return classifiesUnknownJsonValue(declaration.recipe.values);
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
"date-shift",
|
|
94
|
+
"enum",
|
|
95
|
+
"validated-string",
|
|
96
|
+
"path-map",
|
|
97
|
+
"binding-substitute",
|
|
98
|
+
].includes(declaration.recipe.kind);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const classifiesContainer = (declaration, expectedType) => {
|
|
102
|
+
if (["REPLACE", "EXCLUDE", "PSEUDONYMIZE"].includes(declaration.action)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (declaration.action !== "DERIVE") return false;
|
|
106
|
+
if (declaration.recipe.kind === "approved-owner") {
|
|
107
|
+
return (
|
|
108
|
+
classifiesContainer(declaration.recipe.approved, expectedType) &&
|
|
109
|
+
classifiesContainer(declaration.recipe.otherwise, expectedType)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (declaration.recipe.kind === "json-union") return true;
|
|
113
|
+
return expectedType === "array"
|
|
114
|
+
? declaration.recipe.kind === "json-array" &&
|
|
115
|
+
classifiesUnknownJsonValue(declaration.recipe.items)
|
|
116
|
+
: declaration.recipe.kind === "json-object"
|
|
117
|
+
? Object.values(declaration.recipe.fields).every(
|
|
118
|
+
classifiesUnknownJsonValue,
|
|
119
|
+
)
|
|
120
|
+
: declaration.recipe.kind === "json-dictionary" &&
|
|
121
|
+
classifiesUnknownJsonValue(declaration.recipe.values);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const validateStructuredJsonRecipe = ({
|
|
125
|
+
recipe,
|
|
126
|
+
label,
|
|
127
|
+
depth,
|
|
128
|
+
maximumPolicyDepth,
|
|
129
|
+
validateDeclaration,
|
|
130
|
+
validatePseudonymRecipe,
|
|
131
|
+
}) => {
|
|
132
|
+
const bounds = boundsFor(recipe, label, maximumPolicyDepth);
|
|
133
|
+
if (recipe.allowNull !== undefined && typeof recipe.allowNull !== "boolean") {
|
|
134
|
+
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
135
|
+
}
|
|
136
|
+
if (recipe.kind === "json-array") {
|
|
137
|
+
knownKeys(
|
|
138
|
+
recipe,
|
|
139
|
+
[
|
|
140
|
+
"kind",
|
|
141
|
+
"items",
|
|
142
|
+
"allowNull",
|
|
143
|
+
"maximumBytes",
|
|
144
|
+
"maximumDepth",
|
|
145
|
+
"maximumItems",
|
|
146
|
+
],
|
|
147
|
+
`${label}.recipe`,
|
|
148
|
+
);
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
kind: "json-array",
|
|
151
|
+
allowNull: recipe.allowNull === true,
|
|
152
|
+
...bounds,
|
|
153
|
+
maximumItems: positiveInteger(
|
|
154
|
+
recipe.maximumItems,
|
|
155
|
+
`${label}.recipe.maximumItems`,
|
|
156
|
+
10_000,
|
|
157
|
+
),
|
|
158
|
+
items: validateDeclaration(recipe.items, `${label}.recipe.items`, {
|
|
159
|
+
depth,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (recipe.kind === "json-union") {
|
|
164
|
+
knownKeys(
|
|
165
|
+
recipe,
|
|
166
|
+
[
|
|
167
|
+
"kind",
|
|
168
|
+
"variants",
|
|
169
|
+
"allowNull",
|
|
170
|
+
"maximumBytes",
|
|
171
|
+
"maximumDepth",
|
|
172
|
+
"maximumItems",
|
|
173
|
+
],
|
|
174
|
+
`${label}.recipe`,
|
|
175
|
+
);
|
|
176
|
+
if (
|
|
177
|
+
!isObject(recipe.variants) ||
|
|
178
|
+
Object.keys(recipe.variants).length === 0
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${label}.recipe.variants must classify JSON value types.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const variants = Object.fromEntries(
|
|
185
|
+
Object.entries(recipe.variants).map(([type, declaration]) => {
|
|
186
|
+
if (!JSON_UNION_VARIANTS.has(type)) {
|
|
187
|
+
throw new Error(`${label}.recipe.variants.${type} is unsupported.`);
|
|
188
|
+
}
|
|
189
|
+
const normalized = validateDeclaration(
|
|
190
|
+
declaration,
|
|
191
|
+
`${label}.recipe.variants.${type}`,
|
|
192
|
+
{ depth },
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
["array", "object"].includes(type) &&
|
|
196
|
+
!classifiesContainer(normalized, type)
|
|
197
|
+
) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${label}.recipe.variants.${type} must classify container contents instead of retaining the whole value.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return [type, normalized];
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
return Object.freeze({
|
|
206
|
+
kind: "json-union",
|
|
207
|
+
allowNull: recipe.allowNull === true,
|
|
208
|
+
...bounds,
|
|
209
|
+
maximumItems: positiveInteger(
|
|
210
|
+
recipe.maximumItems,
|
|
211
|
+
`${label}.recipe.maximumItems`,
|
|
212
|
+
10_000,
|
|
213
|
+
),
|
|
214
|
+
variants: Object.freeze(variants),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (recipe.kind === "json-dictionary") {
|
|
218
|
+
knownKeys(
|
|
219
|
+
recipe,
|
|
220
|
+
[
|
|
221
|
+
"kind",
|
|
222
|
+
"keys",
|
|
223
|
+
"values",
|
|
224
|
+
"allowNull",
|
|
225
|
+
"maximumBytes",
|
|
226
|
+
"maximumDepth",
|
|
227
|
+
"maximumItems",
|
|
228
|
+
],
|
|
229
|
+
`${label}.recipe`,
|
|
230
|
+
);
|
|
231
|
+
if (!isObject(recipe.keys)) {
|
|
232
|
+
throw new Error(`${label}.recipe.keys must be an object.`);
|
|
233
|
+
}
|
|
234
|
+
knownKeys(
|
|
235
|
+
recipe.keys,
|
|
236
|
+
["format", "action", "minimum", "maximum", "maximumLength", "recipe"],
|
|
237
|
+
`${label}.recipe.keys`,
|
|
238
|
+
);
|
|
239
|
+
if (!DICTIONARY_KEY_FORMATS.has(recipe.keys.format)) {
|
|
240
|
+
throw new Error(`${label}.recipe.keys.format is unsupported.`);
|
|
241
|
+
}
|
|
242
|
+
if (!["KEEP", "PSEUDONYMIZE"].includes(recipe.keys.action)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`${label}.recipe.keys.action must be KEEP or PSEUDONYMIZE.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const maximumLength =
|
|
248
|
+
recipe.keys.format === "text"
|
|
249
|
+
? positiveInteger(
|
|
250
|
+
recipe.keys.maximumLength,
|
|
251
|
+
`${label}.recipe.keys.maximumLength`,
|
|
252
|
+
1_024,
|
|
253
|
+
)
|
|
254
|
+
: undefined;
|
|
255
|
+
const integerRange =
|
|
256
|
+
recipe.keys.format === "integer"
|
|
257
|
+
? {
|
|
258
|
+
minimum: safeInteger(
|
|
259
|
+
recipe.keys.minimum,
|
|
260
|
+
`${label}.recipe.keys.minimum`,
|
|
261
|
+
),
|
|
262
|
+
maximum: safeInteger(
|
|
263
|
+
recipe.keys.maximum,
|
|
264
|
+
`${label}.recipe.keys.maximum`,
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
: null;
|
|
268
|
+
if (integerRange !== null && integerRange.minimum > integerRange.maximum) {
|
|
269
|
+
throw new Error(`${label}.recipe.keys.minimum must not exceed maximum.`);
|
|
270
|
+
}
|
|
271
|
+
if (
|
|
272
|
+
recipe.keys.format !== "integer" &&
|
|
273
|
+
(recipe.keys.minimum !== undefined || recipe.keys.maximum !== undefined)
|
|
274
|
+
) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${label}.recipe.keys.minimum and maximum are supported only for integer keys.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
recipe.keys.format !== "text" &&
|
|
281
|
+
recipe.keys.maximumLength !== undefined
|
|
282
|
+
) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`${label}.recipe.keys.maximumLength is supported only for text keys.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const keyRecipe =
|
|
288
|
+
recipe.keys.action === "PSEUDONYMIZE"
|
|
289
|
+
? validatePseudonymRecipe(
|
|
290
|
+
recipe.keys.recipe,
|
|
291
|
+
`${label}.recipe.keys`,
|
|
292
|
+
depth + 1,
|
|
293
|
+
)
|
|
294
|
+
: null;
|
|
295
|
+
if (recipe.keys.action === "KEEP" && recipe.keys.recipe !== undefined) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`${label}.recipe.keys.recipe is not allowed when keys are kept.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const values = validateDeclaration(
|
|
301
|
+
recipe.values,
|
|
302
|
+
`${label}.recipe.values`,
|
|
303
|
+
{ depth },
|
|
304
|
+
);
|
|
305
|
+
if (!classifiesUnknownJsonValue(values)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`${label}.recipe.values must classify value types instead of retaining unknown values.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return Object.freeze({
|
|
311
|
+
kind: "json-dictionary",
|
|
312
|
+
allowNull: recipe.allowNull === true,
|
|
313
|
+
...bounds,
|
|
314
|
+
maximumItems: positiveInteger(
|
|
315
|
+
recipe.maximumItems,
|
|
316
|
+
`${label}.recipe.maximumItems`,
|
|
317
|
+
10_000,
|
|
318
|
+
),
|
|
319
|
+
keys: Object.freeze({
|
|
320
|
+
format: recipe.keys.format,
|
|
321
|
+
action: recipe.keys.action,
|
|
322
|
+
...(integerRange === null ? {} : integerRange),
|
|
323
|
+
...(maximumLength === undefined ? {} : { maximumLength }),
|
|
324
|
+
...(keyRecipe === null ? {} : { recipe: keyRecipe }),
|
|
325
|
+
}),
|
|
326
|
+
values,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
|
|
330
|
+
throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
|
|
331
|
+
}
|
|
332
|
+
knownKeys(
|
|
333
|
+
recipe,
|
|
334
|
+
["kind", "fields", "allowNull", "maximumBytes", "maximumDepth"],
|
|
335
|
+
`${label}.recipe`,
|
|
336
|
+
);
|
|
337
|
+
const fields = Object.fromEntries(
|
|
338
|
+
Object.entries(recipe.fields).map(([key, declaration]) => {
|
|
339
|
+
if (!key) {
|
|
340
|
+
throw new Error(`${label}.recipe.fields contains an invalid key.`);
|
|
341
|
+
}
|
|
342
|
+
return [
|
|
343
|
+
key,
|
|
344
|
+
validateDeclaration(declaration, `${label}.recipe.fields.${key}`, {
|
|
345
|
+
allowRequired: true,
|
|
346
|
+
depth,
|
|
347
|
+
}),
|
|
348
|
+
];
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
return Object.freeze({
|
|
352
|
+
kind: "json-object",
|
|
353
|
+
allowNull: recipe.allowNull === true,
|
|
354
|
+
...bounds,
|
|
355
|
+
fields: Object.freeze(fields),
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
export const structuredJsonDeclarations = (recipe) => {
|
|
360
|
+
if (recipe?.kind === "json-object") return Object.values(recipe.fields);
|
|
361
|
+
if (recipe?.kind === "json-array") return [recipe.items];
|
|
362
|
+
if (recipe?.kind === "json-union") return Object.values(recipe.variants);
|
|
363
|
+
if (recipe?.kind === "json-dictionary") return [recipe.values];
|
|
364
|
+
return [];
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const assertJsonBounds = (value, recipe, path) => {
|
|
368
|
+
let bytes = 0;
|
|
369
|
+
const jsonStringBytes = (string) => {
|
|
370
|
+
let size = 2;
|
|
371
|
+
for (let index = 0; index < string.length; index += 1) {
|
|
372
|
+
const code = string.charCodeAt(index);
|
|
373
|
+
if (code === 34 || code === 92) size += 2;
|
|
374
|
+
else if (code < 32) size += [8, 9, 10, 12, 13].includes(code) ? 2 : 6;
|
|
375
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
376
|
+
const next = string.charCodeAt(index + 1);
|
|
377
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
378
|
+
size += 4;
|
|
379
|
+
index += 1;
|
|
380
|
+
} else size += 6;
|
|
381
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) size += 6;
|
|
382
|
+
else if (code <= 0x7f) size += 1;
|
|
383
|
+
else if (code <= 0x7ff) size += 2;
|
|
384
|
+
else size += 3;
|
|
385
|
+
}
|
|
386
|
+
return size;
|
|
387
|
+
};
|
|
388
|
+
const visit = (current, depth) => {
|
|
389
|
+
if (depth > recipe.maximumDepth) {
|
|
390
|
+
throw new Error(`${path} exceeds maximumDepth ${recipe.maximumDepth}.`);
|
|
391
|
+
}
|
|
392
|
+
if (typeof current === "string") bytes += jsonStringBytes(current);
|
|
393
|
+
else if (current === null) bytes += 4;
|
|
394
|
+
else if (typeof current === "number" && !Number.isFinite(current)) {
|
|
395
|
+
throw new Error(`${path} contains an unsupported JSON value.`);
|
|
396
|
+
} else if (["number", "boolean"].includes(typeof current)) {
|
|
397
|
+
bytes += String(current).length;
|
|
398
|
+
} else if (Array.isArray(current)) {
|
|
399
|
+
if (
|
|
400
|
+
recipe.kind === "json-array" &&
|
|
401
|
+
current.length > recipe.maximumItems
|
|
402
|
+
) {
|
|
403
|
+
throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
|
|
404
|
+
}
|
|
405
|
+
bytes += 2 + Math.max(0, current.length - 1);
|
|
406
|
+
for (const item of current) visit(item, depth + 1);
|
|
407
|
+
} else if (isObject(current)) {
|
|
408
|
+
bytes += 2 + Math.max(0, Object.keys(current).length - 1);
|
|
409
|
+
for (const [key, nested] of Object.entries(current)) {
|
|
410
|
+
bytes += jsonStringBytes(key) + 1;
|
|
411
|
+
visit(nested, depth + 1);
|
|
412
|
+
}
|
|
413
|
+
} else throw new Error(`${path} contains an unsupported JSON value.`);
|
|
414
|
+
if (bytes > recipe.maximumBytes) {
|
|
415
|
+
throw new Error(`${path} exceeds maximumBytes ${recipe.maximumBytes}.`);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
visit(value, 1);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const jsonValueType = (value) => {
|
|
422
|
+
if (Array.isArray(value)) return "array";
|
|
423
|
+
if (isObject(value)) return "object";
|
|
424
|
+
if (["string", "number", "boolean"].includes(typeof value)) {
|
|
425
|
+
return typeof value;
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const validateDictionaryKey = (value, rule, path) => {
|
|
431
|
+
if (rule.format === "integer") {
|
|
432
|
+
if (!/^(?:0|-?[1-9][0-9]{0,15})$/u.test(value)) {
|
|
433
|
+
throw new Error(`${path} contains an invalid integer key.`);
|
|
434
|
+
}
|
|
435
|
+
const integer = Number(value);
|
|
436
|
+
if (
|
|
437
|
+
!Number.isSafeInteger(integer) ||
|
|
438
|
+
String(integer) !== value ||
|
|
439
|
+
integer < rule.minimum ||
|
|
440
|
+
integer > rule.maximum
|
|
441
|
+
) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`${path} contains an integer key outside the reviewed range ${rule.minimum} through ${rule.maximum}.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return integer;
|
|
447
|
+
} else if (rule.format === "positive-integer") {
|
|
448
|
+
if (!/^[1-9][0-9]{0,18}$/u.test(value)) {
|
|
449
|
+
throw new Error(`${path} contains an invalid positive-integer key.`);
|
|
450
|
+
}
|
|
451
|
+
} else if (rule.format === "uuid") {
|
|
452
|
+
if (!UUID.test(value)) {
|
|
453
|
+
throw new Error(`${path} contains an invalid uuid key.`);
|
|
454
|
+
}
|
|
455
|
+
} else if (rule.format === "identifier") {
|
|
456
|
+
if (!IDENTIFIER.test(value)) {
|
|
457
|
+
throw new Error(`${path} contains an invalid identifier key.`);
|
|
458
|
+
}
|
|
459
|
+
} else if (
|
|
460
|
+
!value ||
|
|
461
|
+
value.length > rule.maximumLength ||
|
|
462
|
+
/[\u0000-\u001f\u007f]/u.test(value)
|
|
463
|
+
) {
|
|
464
|
+
throw new Error(`${path} contains an invalid bounded text key.`);
|
|
465
|
+
}
|
|
466
|
+
return value;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
export const executeStructuredJsonDeclaration = ({
|
|
470
|
+
declaration,
|
|
471
|
+
value,
|
|
472
|
+
key,
|
|
473
|
+
path,
|
|
474
|
+
execute,
|
|
475
|
+
pseudonymize,
|
|
476
|
+
}) => {
|
|
477
|
+
const recipe = declaration.recipe;
|
|
478
|
+
if (value === null && recipe.allowNull) return null;
|
|
479
|
+
assertJsonBounds(value, recipe, path);
|
|
480
|
+
if (recipe.kind === "json-union") {
|
|
481
|
+
const type = jsonValueType(value);
|
|
482
|
+
if (type === null) {
|
|
483
|
+
throw new Error(`${path} contains an unsupported JSON value.`);
|
|
484
|
+
}
|
|
485
|
+
if (
|
|
486
|
+
["array", "object"].includes(type) &&
|
|
487
|
+
Object.keys(value).length > recipe.maximumItems
|
|
488
|
+
) {
|
|
489
|
+
throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
|
|
490
|
+
}
|
|
491
|
+
const variant = recipe.variants[type];
|
|
492
|
+
if (!variant) {
|
|
493
|
+
throw new Error(`${path} has an unreviewed JSON ${type} variant.`);
|
|
494
|
+
}
|
|
495
|
+
return execute(variant, value, path);
|
|
496
|
+
}
|
|
497
|
+
if (recipe.kind === "json-array") {
|
|
498
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
|
|
499
|
+
return value.flatMap((item, index) => {
|
|
500
|
+
const transformed = execute(recipe.items, item, `${path}[${index}]`);
|
|
501
|
+
return transformed === undefined ? [] : [transformed];
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (recipe.kind === "json-dictionary") {
|
|
505
|
+
if (!isObject(value)) {
|
|
506
|
+
throw new Error(`${path} must be a JSON dictionary object.`);
|
|
507
|
+
}
|
|
508
|
+
const entries = Object.entries(value);
|
|
509
|
+
if (entries.length > recipe.maximumItems) {
|
|
510
|
+
throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
|
|
511
|
+
}
|
|
512
|
+
const transformed = [];
|
|
513
|
+
const transformedKeys = new Set();
|
|
514
|
+
for (const [sourceKey, nestedValue] of entries) {
|
|
515
|
+
const canonicalSourceKey = validateDictionaryKey(
|
|
516
|
+
sourceKey,
|
|
517
|
+
recipe.keys,
|
|
518
|
+
path,
|
|
519
|
+
);
|
|
520
|
+
const destinationKey =
|
|
521
|
+
recipe.keys.action === "KEEP"
|
|
522
|
+
? sourceKey
|
|
523
|
+
: String(pseudonymize(key, recipe.keys.recipe, canonicalSourceKey));
|
|
524
|
+
if (transformedKeys.has(destinationKey)) {
|
|
525
|
+
throw new Error(`${path} produced a duplicate transformed JSON key.`);
|
|
526
|
+
}
|
|
527
|
+
transformedKeys.add(destinationKey);
|
|
528
|
+
const nested = execute(
|
|
529
|
+
recipe.values,
|
|
530
|
+
nestedValue,
|
|
531
|
+
`${path}.${sourceKey}`,
|
|
532
|
+
);
|
|
533
|
+
if (nested !== undefined) transformed.push([destinationKey, nested]);
|
|
534
|
+
}
|
|
535
|
+
return Object.fromEntries(transformed);
|
|
536
|
+
}
|
|
537
|
+
if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
|
|
538
|
+
const actual = Object.keys(value).sort();
|
|
539
|
+
const expected = Object.keys(recipe.fields);
|
|
540
|
+
if (actual.some((field) => !expected.includes(field))) {
|
|
541
|
+
throw new Error(`${path} contains an unclassified JSON key.`);
|
|
542
|
+
}
|
|
543
|
+
const missing = expected.filter(
|
|
544
|
+
(field) => recipe.fields[field].required && !Object.hasOwn(value, field),
|
|
545
|
+
);
|
|
546
|
+
if (missing.length) throw new Error(`${path} is missing required JSON keys.`);
|
|
547
|
+
return Object.fromEntries(
|
|
548
|
+
Object.entries(recipe.fields).flatMap(([field, nested]) => {
|
|
549
|
+
if (!Object.hasOwn(value, field)) return [];
|
|
550
|
+
const transformed = execute(nested, value[field], `${path}.${field}`);
|
|
551
|
+
return transformed === undefined ? [] : [[field, transformed]];
|
|
552
|
+
}),
|
|
553
|
+
);
|
|
554
|
+
};
|
package/src/cli/rehearsal.mjs
CHANGED
|
@@ -165,7 +165,8 @@ const runSetup = async ({ flags, planOptions }) => {
|
|
|
165
165
|
return {
|
|
166
166
|
...result,
|
|
167
167
|
readiness,
|
|
168
|
-
nextAction:
|
|
168
|
+
nextAction: [
|
|
169
|
+
`Review ${result.configurationPath}.`,
|
|
169
170
|
readiness.state === "READY"
|
|
170
171
|
? "Next: run rehearsal explain to review the migration plan."
|
|
171
172
|
: needsBaselinePolicy
|
|
@@ -173,6 +174,7 @@ const runSetup = async ({ flags, planOptions }) => {
|
|
|
173
174
|
? "Next: choose Prepare the script to create a reviewable policy draft."
|
|
174
175
|
: "Next: run rehearsal again and choose Prepare a reviewable baseline policy draft."
|
|
175
176
|
: "Next: complete the remaining readiness items shown above.",
|
|
177
|
+
].join("\n"),
|
|
176
178
|
};
|
|
177
179
|
};
|
|
178
180
|
|
package/src/cli/renderers.mjs
CHANGED
|
@@ -270,6 +270,18 @@ export const renderSetup = (result) => {
|
|
|
270
270
|
return [
|
|
271
271
|
`REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
|
|
272
272
|
`Project: ${result.project}`,
|
|
273
|
+
`Configuration: ${result.configurationPath} (${
|
|
274
|
+
result.configurationAction === "create"
|
|
275
|
+
? result.mode === "written"
|
|
276
|
+
? "created"
|
|
277
|
+
: "will create"
|
|
278
|
+
: "existing"
|
|
279
|
+
})`,
|
|
280
|
+
...(result.rootConfigGenerationSkipped
|
|
281
|
+
? [
|
|
282
|
+
"Root config: not created because the existing supported configuration remains active.",
|
|
283
|
+
]
|
|
284
|
+
: []),
|
|
273
285
|
`Database: ${result.target === "postgresql" ? "PostgreSQL" : "Supabase"}`,
|
|
274
286
|
`Runtime ID: ${result.projectId}`,
|
|
275
287
|
`Application: ${result.applicationUrl}`,
|
|
@@ -28,6 +28,7 @@ import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
|
|
|
28
28
|
import {
|
|
29
29
|
applyPostgresqlSourceAccess,
|
|
30
30
|
planPostgresqlSourceAccessRetirement,
|
|
31
|
+
resolvePostgresqlSourceStatePaths,
|
|
31
32
|
retirePostgresqlSourceAccess,
|
|
32
33
|
} from "../source/postgresql_access.mjs";
|
|
33
34
|
import { loadRuntimeTopology } from "../runtime/topology.mjs";
|
|
@@ -54,6 +55,7 @@ export const runSourceAccess = async ({ flags, planOptions }) => {
|
|
|
54
55
|
plan,
|
|
55
56
|
confirmation: flags.sourceAccessConfirmation,
|
|
56
57
|
projectRoot: loaded.projectRoot,
|
|
58
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
57
59
|
});
|
|
58
60
|
return {
|
|
59
61
|
mode: "written",
|
|
@@ -67,6 +69,8 @@ export const runSourceRetirement = async ({ flags, planOptions }) => {
|
|
|
67
69
|
const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
|
|
68
70
|
const plan = await planPostgresqlSourceAccessRetirement({
|
|
69
71
|
projectRoot: loaded.projectRoot,
|
|
72
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
73
|
+
credentialFile: sourcePolicy.reader.credentialFile,
|
|
70
74
|
targetFingerprint: sourcePolicy.targetFingerprint,
|
|
71
75
|
});
|
|
72
76
|
if (!flags.sourceRetirementConfirmation) return { mode: "preview", plan };
|
|
@@ -76,6 +80,7 @@ export const runSourceRetirement = async ({ flags, planOptions }) => {
|
|
|
76
80
|
administratorEnvironmentVariable:
|
|
77
81
|
sourcePolicy.administratorEnvironmentVariable,
|
|
78
82
|
projectRoot: loaded.projectRoot,
|
|
83
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
79
84
|
credentialFile: sourcePolicy.reader.credentialFile,
|
|
80
85
|
});
|
|
81
86
|
return { mode: "written", plan };
|
|
@@ -102,6 +107,11 @@ const readSourceReaderCredential = async (path) => {
|
|
|
102
107
|
|
|
103
108
|
export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
104
109
|
const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
|
|
110
|
+
const { credentialPath } = resolvePostgresqlSourceStatePaths({
|
|
111
|
+
projectRoot: loaded.projectRoot,
|
|
112
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
113
|
+
credentialFile: sourcePolicy.reader.credentialFile,
|
|
114
|
+
});
|
|
105
115
|
const [
|
|
106
116
|
sourceAccessReceipt,
|
|
107
117
|
privacyPolicyBytes,
|
|
@@ -114,9 +124,7 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
114
124
|
).then(JSON.parse),
|
|
115
125
|
readFile(loaded.paths.sanitizationPolicy),
|
|
116
126
|
readPrivacyKey(loaded.paths.privacyKey),
|
|
117
|
-
readSourceReaderCredential(
|
|
118
|
-
join(loaded.projectRoot, sourcePolicy.reader.credentialFile),
|
|
119
|
-
),
|
|
127
|
+
readSourceReaderCredential(credentialPath),
|
|
120
128
|
]);
|
|
121
129
|
const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
|
|
122
130
|
const assetPrivacyEngine = createPrivacyEngine({
|
package/src/identity/claim.mjs
CHANGED
|
@@ -740,18 +740,18 @@ export const applyIdentityClaim = async ({
|
|
|
740
740
|
"select 1 from auth.users where id = $1::uuid limit 1",
|
|
741
741
|
[plan.identity.placeholderUserId],
|
|
742
742
|
);
|
|
743
|
-
const placeholderReferenceCounts =
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
743
|
+
const placeholderReferenceCounts = [];
|
|
744
|
+
if (localUserId !== plan.identity.placeholderUserId) {
|
|
745
|
+
for (const reference of plan.identity.references) {
|
|
746
|
+
placeholderReferenceCounts.push(
|
|
747
|
+
await countIdentityRows({
|
|
748
|
+
client,
|
|
749
|
+
reference,
|
|
750
|
+
userId: plan.identity.placeholderUserId,
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
755
|
const transferReferences = plan.identity.references.filter(
|
|
756
756
|
(reference) => reference.strategy === "transfer",
|
|
757
757
|
);
|
|
@@ -762,15 +762,17 @@ export const applyIdentityClaim = async ({
|
|
|
762
762
|
const index = plan.identity.references.indexOf(reference);
|
|
763
763
|
return placeholderReferenceCounts[index] ?? 0;
|
|
764
764
|
});
|
|
765
|
-
const transferLocalCounts =
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
765
|
+
const transferLocalCounts = [];
|
|
766
|
+
if (
|
|
767
|
+
localUserId !== plan.identity.placeholderUserId &&
|
|
768
|
+
preservedReferences.length > 0
|
|
769
|
+
) {
|
|
770
|
+
for (const reference of transferReferences) {
|
|
771
|
+
transferLocalCounts.push(
|
|
772
|
+
await countIdentityRows({ client, reference, userId: localUserId }),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
774
776
|
const alreadyClaimed =
|
|
775
777
|
localUserId !== plan.identity.placeholderUserId &&
|
|
776
778
|
transferPlaceholderCounts.every((count) => count === 0) &&
|