@rehearsal-db/core 0.1.0-beta.10 → 0.1.0-beta.11
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 +28 -1
- package/COMPATIBILITY.md +9 -0
- package/README.md +18 -7
- package/docs/README.md +25 -16
- package/docs/commands.md +10 -4
- package/docs/configuration.md +39 -9
- package/docs/getting-started.md +13 -4
- package/docs/index.md +47 -0
- package/docs/runtime-policies.md +65 -5
- package/docs/sanitization.md +104 -3
- package/docs/standalone-workflow.md +2 -0
- package/docs/troubleshooting.md +25 -0
- package/package.json +10 -2
- package/scripts/install/create_root_config.mjs +36 -0
- package/src/baseline/privacy_engine.mjs +358 -30
- package/src/cli/init_command.mjs +77 -0
- package/src/cli/rehearsal.mjs +6 -51
- package/src/cli/renderers.mjs +7 -2
- package/src/identity/claim.mjs +232 -44
- package/src/project/configuration.mjs +4 -2
- package/src/project/install_scaffold.mjs +291 -0
- package/src/project/setup.mjs +157 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rehearsal-db/core",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Safely rehearse PostgreSQL and Supabase migrations against sanitized, production-shaped data.",
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
|
43
|
+
"scripts/install/create_root_config.mjs",
|
|
43
44
|
"scripts/runtime/manage_database.mjs",
|
|
44
45
|
"docs/*.md",
|
|
45
46
|
"README.md",
|
|
@@ -51,6 +52,9 @@
|
|
|
51
52
|
"LICENSE"
|
|
52
53
|
],
|
|
53
54
|
"scripts": {
|
|
55
|
+
"postinstall": "node scripts/install/create_root_config.mjs",
|
|
56
|
+
"docs:build": "node scripts/docs/build.mjs",
|
|
57
|
+
"docs:preview": "node scripts/docs/serve.mjs",
|
|
54
58
|
"test": "vitest run",
|
|
55
59
|
"test:unit": "vitest run tests/unit",
|
|
56
60
|
"test:integration": "vitest run tests/integration tests/contracts",
|
|
@@ -59,7 +63,8 @@
|
|
|
59
63
|
"test:fixture:postgresql": "node scripts/verification/fixtures/postgresql.mjs",
|
|
60
64
|
"test:fixture:dependent": "node scripts/verification/fixtures/dependent.mjs",
|
|
61
65
|
"test:fixture:standalone": "node scripts/verification/fixtures/standalone.mjs",
|
|
62
|
-
"
|
|
66
|
+
"test:fixture:privacy": "node scripts/verification/fixtures/privacy.mjs",
|
|
67
|
+
"check": "npm run check:syntax && npm run test && npm run docs:build && npm run format:check && npm run package:audit",
|
|
63
68
|
"check:syntax": "find src scripts tests -type f -name '*.mjs' -exec node --check {} +",
|
|
64
69
|
"format": "prettier --write .",
|
|
65
70
|
"format:check": "prettier --check .",
|
|
@@ -78,7 +83,10 @@
|
|
|
78
83
|
"pg": "^8.23.1"
|
|
79
84
|
},
|
|
80
85
|
"devDependencies": {
|
|
86
|
+
"@fontsource-variable/dm-sans": "^5.3.0",
|
|
87
|
+
"@fontsource-variable/fraunces": "^5.3.0",
|
|
81
88
|
"@lydell/node-pty": "^1.2.0-beta.15",
|
|
89
|
+
"marked": "^18.0.14",
|
|
82
90
|
"prettier": "^3.9.6",
|
|
83
91
|
"vitest": "^5.0.2"
|
|
84
92
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Create safe project-root Rehearsal scaffolding during a local install. */
|
|
3
|
+
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createInstalledRehearsalScaffold } from "../../src/project/install_scaffold.mjs";
|
|
6
|
+
|
|
7
|
+
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const result = await createInstalledRehearsalScaffold({ packageRoot });
|
|
11
|
+
if (result.status === "ready") {
|
|
12
|
+
const changed = result.files.filter((file) => file.action !== "unchanged");
|
|
13
|
+
console.log(
|
|
14
|
+
[
|
|
15
|
+
"Rehearsal is ready in this project.",
|
|
16
|
+
...(changed.length
|
|
17
|
+
? [
|
|
18
|
+
...changed.map(
|
|
19
|
+
(file) =>
|
|
20
|
+
` ${file.action === "create" ? "+" : "~"} ${file.path}`,
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
: [" Existing Rehearsal configuration preserved."]),
|
|
24
|
+
"Next: review rehearsal.config.mjs, then run npx rehearsal.",
|
|
25
|
+
].join("\n"),
|
|
26
|
+
);
|
|
27
|
+
} else {
|
|
28
|
+
console.log(
|
|
29
|
+
`Rehearsal did not create project files: ${result.reason}.\nNext: run npx rehearsal from the application directory.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`Rehearsal could not safely create project files during installation: ${String(error?.message ?? error)}\nNext: run npx rehearsal from the application directory.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -9,6 +9,8 @@ import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
|
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
10
|
|
|
11
11
|
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
12
|
+
const BINDING_NAME = /^[a-z][a-z0-9-]{0,62}$/u;
|
|
13
|
+
const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
12
14
|
const HEX_64 = /^[a-f0-9]{64}$/u;
|
|
13
15
|
const ACTIONS = new Set([
|
|
14
16
|
"KEEP",
|
|
@@ -18,8 +20,16 @@ const ACTIONS = new Set([
|
|
|
18
20
|
"DERIVE",
|
|
19
21
|
]);
|
|
20
22
|
const PSEUDONYM_FORMATS = new Set(["uuid", "email", "text", "integer"]);
|
|
21
|
-
const DERIVATION_KINDS = new Set([
|
|
23
|
+
const DERIVATION_KINDS = new Set([
|
|
24
|
+
"date-shift",
|
|
25
|
+
"json-object",
|
|
26
|
+
"json-array",
|
|
27
|
+
"approved-owner",
|
|
28
|
+
]);
|
|
22
29
|
const KEY_BYTES = 32;
|
|
30
|
+
const DEFAULT_JSON_MAXIMUM_BYTES = 65_536;
|
|
31
|
+
const DEFAULT_JSON_MAXIMUM_DEPTH = 8;
|
|
32
|
+
const MAXIMUM_POLICY_DEPTH = 16;
|
|
23
33
|
|
|
24
34
|
const isObject = (value) =>
|
|
25
35
|
value !== null &&
|
|
@@ -54,7 +64,37 @@ const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
|
|
|
54
64
|
return value;
|
|
55
65
|
};
|
|
56
66
|
|
|
57
|
-
const
|
|
67
|
+
const validateNestedDeclaration = (
|
|
68
|
+
declaration,
|
|
69
|
+
label,
|
|
70
|
+
{ allowRequired = false, depth = 0 } = {},
|
|
71
|
+
) => {
|
|
72
|
+
if (!isObject(declaration)) throw new Error(`${label} must be an object.`);
|
|
73
|
+
knownKeys(
|
|
74
|
+
declaration,
|
|
75
|
+
allowRequired ? ["action", "recipe", "required"] : ["action", "recipe"],
|
|
76
|
+
label,
|
|
77
|
+
);
|
|
78
|
+
if (
|
|
79
|
+
allowRequired &&
|
|
80
|
+
declaration.required !== undefined &&
|
|
81
|
+
typeof declaration.required !== "boolean"
|
|
82
|
+
) {
|
|
83
|
+
throw new Error(`${label}.required must be true or false.`);
|
|
84
|
+
}
|
|
85
|
+
const action = String(declaration.action ?? "").toUpperCase();
|
|
86
|
+
if (!ACTIONS.has(action)) throw new Error(`${label}.action is unsupported.`);
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
action,
|
|
89
|
+
recipe: validateRecipe(action, declaration.recipe, label, depth + 1),
|
|
90
|
+
...(allowRequired ? { required: declaration.required !== false } : {}),
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
95
|
+
if (depth > MAXIMUM_POLICY_DEPTH) {
|
|
96
|
+
throw new Error(`${label}.recipe exceeds the supported nesting depth.`);
|
|
97
|
+
}
|
|
58
98
|
if (action === "KEEP" || action === "EXCLUDE") {
|
|
59
99
|
if (recipe !== undefined && recipe !== null) {
|
|
60
100
|
throw new Error(`${label}.recipe is not allowed for ${action}.`);
|
|
@@ -97,55 +137,141 @@ const validateRecipe = (action, recipe, label) => {
|
|
|
97
137
|
JSON.stringify(recipe.value);
|
|
98
138
|
return Object.freeze({ kind: "constant", value: recipe.value });
|
|
99
139
|
}
|
|
100
|
-
knownKeys(
|
|
140
|
+
knownKeys(
|
|
141
|
+
recipe,
|
|
142
|
+
[
|
|
143
|
+
"kind",
|
|
144
|
+
"days",
|
|
145
|
+
"fields",
|
|
146
|
+
"items",
|
|
147
|
+
"approved",
|
|
148
|
+
"otherwise",
|
|
149
|
+
"allowNull",
|
|
150
|
+
"maximumBytes",
|
|
151
|
+
"maximumDepth",
|
|
152
|
+
"maximumItems",
|
|
153
|
+
],
|
|
154
|
+
`${label}.recipe`,
|
|
155
|
+
);
|
|
101
156
|
if (!DERIVATION_KINDS.has(recipe.kind)) {
|
|
102
157
|
throw new Error(`${label}.recipe.kind is unsupported.`);
|
|
103
158
|
}
|
|
104
159
|
if (recipe.kind === "date-shift") {
|
|
105
160
|
const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
|
|
106
|
-
if (recipe.
|
|
161
|
+
if (Object.keys(recipe).some((key) => !["kind", "days"].includes(key))) {
|
|
107
162
|
throw new Error(
|
|
108
163
|
`${label}.recipe contains fields not used by date-shift.`,
|
|
109
164
|
);
|
|
110
165
|
}
|
|
111
166
|
return Object.freeze({ kind: "date-shift", days });
|
|
112
167
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
168
|
+
if (recipe.kind === "approved-owner") {
|
|
169
|
+
if (
|
|
170
|
+
Object.keys(recipe).some(
|
|
171
|
+
(key) => !["kind", "approved", "otherwise"].includes(key),
|
|
172
|
+
)
|
|
173
|
+
) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${label}.recipe contains fields not used by approved-owner.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
kind: "approved-owner",
|
|
180
|
+
approved: validateNestedDeclaration(
|
|
181
|
+
recipe.approved,
|
|
182
|
+
`${label}.recipe.approved`,
|
|
183
|
+
{ depth },
|
|
184
|
+
),
|
|
185
|
+
otherwise: validateNestedDeclaration(
|
|
186
|
+
recipe.otherwise,
|
|
187
|
+
`${label}.recipe.otherwise`,
|
|
188
|
+
{ depth },
|
|
189
|
+
),
|
|
190
|
+
});
|
|
115
191
|
}
|
|
116
192
|
if (recipe.allowNull !== undefined && typeof recipe.allowNull !== "boolean") {
|
|
117
193
|
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
118
194
|
}
|
|
195
|
+
const bounds = Object.freeze({
|
|
196
|
+
maximumBytes: positiveInteger(
|
|
197
|
+
recipe.maximumBytes ?? DEFAULT_JSON_MAXIMUM_BYTES,
|
|
198
|
+
`${label}.recipe.maximumBytes`,
|
|
199
|
+
16_777_216,
|
|
200
|
+
),
|
|
201
|
+
maximumDepth: positiveInteger(
|
|
202
|
+
recipe.maximumDepth ?? DEFAULT_JSON_MAXIMUM_DEPTH,
|
|
203
|
+
`${label}.recipe.maximumDepth`,
|
|
204
|
+
MAXIMUM_POLICY_DEPTH,
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
if (recipe.kind === "json-array") {
|
|
208
|
+
if (
|
|
209
|
+
Object.keys(recipe).some(
|
|
210
|
+
(key) =>
|
|
211
|
+
![
|
|
212
|
+
"kind",
|
|
213
|
+
"items",
|
|
214
|
+
"allowNull",
|
|
215
|
+
"maximumBytes",
|
|
216
|
+
"maximumDepth",
|
|
217
|
+
"maximumItems",
|
|
218
|
+
].includes(key),
|
|
219
|
+
)
|
|
220
|
+
) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${label}.recipe contains fields not used by json-array.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return Object.freeze({
|
|
226
|
+
kind: "json-array",
|
|
227
|
+
allowNull: recipe.allowNull === true,
|
|
228
|
+
...bounds,
|
|
229
|
+
maximumItems: positiveInteger(
|
|
230
|
+
recipe.maximumItems,
|
|
231
|
+
`${label}.recipe.maximumItems`,
|
|
232
|
+
10_000,
|
|
233
|
+
),
|
|
234
|
+
items: validateNestedDeclaration(recipe.items, `${label}.recipe.items`, {
|
|
235
|
+
depth,
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
|
|
240
|
+
throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
Object.keys(recipe).some(
|
|
244
|
+
(key) =>
|
|
245
|
+
![
|
|
246
|
+
"kind",
|
|
247
|
+
"fields",
|
|
248
|
+
"allowNull",
|
|
249
|
+
"maximumBytes",
|
|
250
|
+
"maximumDepth",
|
|
251
|
+
].includes(key),
|
|
252
|
+
)
|
|
253
|
+
) {
|
|
254
|
+
throw new Error(`${label}.recipe contains fields not used by json-object.`);
|
|
255
|
+
}
|
|
119
256
|
const fields = Object.fromEntries(
|
|
120
257
|
Object.entries(recipe.fields).map(([key, declaration]) => {
|
|
121
|
-
if (!key
|
|
258
|
+
if (!key) {
|
|
122
259
|
throw new Error(`${label}.recipe.fields contains an invalid key.`);
|
|
123
260
|
}
|
|
124
|
-
knownKeys(
|
|
125
|
-
declaration,
|
|
126
|
-
["action", "recipe"],
|
|
127
|
-
`${label}.recipe.fields.${key}`,
|
|
128
|
-
);
|
|
129
|
-
const nestedAction = String(declaration.action ?? "").toUpperCase();
|
|
130
|
-
if (!ACTIONS.has(nestedAction)) {
|
|
131
|
-
throw new Error(`${label}.recipe.fields.${key}.action is unsupported.`);
|
|
132
|
-
}
|
|
133
261
|
return [
|
|
134
262
|
key,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
recipe
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
`${label}.recipe.fields.${key}`,
|
|
141
|
-
),
|
|
142
|
-
}),
|
|
263
|
+
validateNestedDeclaration(
|
|
264
|
+
declaration,
|
|
265
|
+
`${label}.recipe.fields.${key}`,
|
|
266
|
+
{ allowRequired: true, depth },
|
|
267
|
+
),
|
|
143
268
|
];
|
|
144
269
|
}),
|
|
145
270
|
);
|
|
146
271
|
return Object.freeze({
|
|
147
272
|
kind: "json-object",
|
|
148
273
|
allowNull: recipe.allowNull === true,
|
|
274
|
+
...bounds,
|
|
149
275
|
fields: Object.freeze(fields),
|
|
150
276
|
});
|
|
151
277
|
};
|
|
@@ -154,7 +280,7 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
154
280
|
if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
|
|
155
281
|
knownKeys(
|
|
156
282
|
policy,
|
|
157
|
-
["policyVersion", "draft", "migrationCutoff", "tables"],
|
|
283
|
+
["policyVersion", "draft", "migrationCutoff", "bindings", "tables"],
|
|
158
284
|
"policy",
|
|
159
285
|
);
|
|
160
286
|
if (policy.policyVersion !== 2) {
|
|
@@ -171,11 +297,41 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
171
297
|
if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
|
|
172
298
|
throw new Error("Privacy policy must classify at least one table.");
|
|
173
299
|
}
|
|
300
|
+
if (policy.bindings !== undefined && !isObject(policy.bindings)) {
|
|
301
|
+
throw new Error("policy.bindings must be an object.");
|
|
302
|
+
}
|
|
303
|
+
const bindings = Object.fromEntries(
|
|
304
|
+
Object.entries(policy.bindings ?? {}).map(([name, binding]) => {
|
|
305
|
+
if (!BINDING_NAME.test(name) || !isObject(binding)) {
|
|
306
|
+
throw new Error("policy.bindings contains an invalid binding.");
|
|
307
|
+
}
|
|
308
|
+
knownKeys(
|
|
309
|
+
binding,
|
|
310
|
+
["environmentVariable", "approvedValueSha256"],
|
|
311
|
+
`policy.bindings.${name}`,
|
|
312
|
+
);
|
|
313
|
+
if (!ENVIRONMENT_KEY.test(binding.environmentVariable ?? "")) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`policy.bindings.${name}.environmentVariable is invalid.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (!HEX_64.test(binding.approvedValueSha256 ?? "")) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`policy.bindings.${name}.approvedValueSha256 must be a lowercase SHA-256 receipt.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return [name, Object.freeze({ ...binding })];
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
174
326
|
const names = new Set();
|
|
175
327
|
const tables = policy.tables.map((table, tableIndex) => {
|
|
176
328
|
const label = `policy.tables[${tableIndex}]`;
|
|
177
329
|
if (!isObject(table)) throw new Error(`${label} must be an object.`);
|
|
178
|
-
knownKeys(
|
|
330
|
+
knownKeys(
|
|
331
|
+
table,
|
|
332
|
+
["schema", "name", "sourceRows", "ownerBinding", "columns"],
|
|
333
|
+
label,
|
|
334
|
+
);
|
|
179
335
|
const schema = identifier(table.schema ?? "public", `${label}.schema`);
|
|
180
336
|
const name = identifier(table.name, `${label}.name`);
|
|
181
337
|
const key = schema === "public" ? name : `${schema}.${name}`;
|
|
@@ -242,16 +398,62 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
242
398
|
recipe: validateRecipe(action, column.recipe, columnLabel),
|
|
243
399
|
});
|
|
244
400
|
});
|
|
401
|
+
let ownerBinding = null;
|
|
402
|
+
if (table.ownerBinding !== undefined && table.ownerBinding !== null) {
|
|
403
|
+
if (!isObject(table.ownerBinding)) {
|
|
404
|
+
throw new Error(`${label}.ownerBinding must be an object.`);
|
|
405
|
+
}
|
|
406
|
+
knownKeys(
|
|
407
|
+
table.ownerBinding,
|
|
408
|
+
["binding", "column"],
|
|
409
|
+
`${label}.ownerBinding`,
|
|
410
|
+
);
|
|
411
|
+
const binding = nonEmpty(
|
|
412
|
+
table.ownerBinding.binding,
|
|
413
|
+
`${label}.ownerBinding.binding`,
|
|
414
|
+
);
|
|
415
|
+
const column = identifier(
|
|
416
|
+
table.ownerBinding.column,
|
|
417
|
+
`${label}.ownerBinding.column`,
|
|
418
|
+
);
|
|
419
|
+
if (!Object.hasOwn(bindings, binding)) {
|
|
420
|
+
throw new Error(`${label}.ownerBinding references an unknown binding.`);
|
|
421
|
+
}
|
|
422
|
+
if (!columnNames.has(column)) {
|
|
423
|
+
throw new Error(`${label}.ownerBinding references an unknown column.`);
|
|
424
|
+
}
|
|
425
|
+
const ownerColumn = columns.find((entry) => entry.name === column);
|
|
426
|
+
if (
|
|
427
|
+
ownerColumn.action !== "PSEUDONYMIZE" ||
|
|
428
|
+
ownerColumn.identity !== "YES"
|
|
429
|
+
) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`${label}.ownerBinding column must be an explicitly pseudonymized identity.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
ownerBinding = Object.freeze({ binding, column });
|
|
435
|
+
}
|
|
436
|
+
const usesApprovedOwner = (declaration) =>
|
|
437
|
+
declaration.recipe?.kind === "approved-owner" ||
|
|
438
|
+
(declaration.recipe?.kind === "json-object" &&
|
|
439
|
+
Object.values(declaration.recipe.fields).some(usesApprovedOwner)) ||
|
|
440
|
+
(declaration.recipe?.kind === "json-array" &&
|
|
441
|
+
usesApprovedOwner(declaration.recipe.items));
|
|
442
|
+
if (columns.some(usesApprovedOwner) && !ownerBinding) {
|
|
443
|
+
throw new Error(`${label} uses approved-owner without ownerBinding.`);
|
|
444
|
+
}
|
|
245
445
|
return Object.freeze({
|
|
246
446
|
schema,
|
|
247
447
|
name,
|
|
248
448
|
sourceRows: table.sourceRows,
|
|
449
|
+
ownerBinding,
|
|
249
450
|
columns: Object.freeze(columns),
|
|
250
451
|
});
|
|
251
452
|
});
|
|
252
453
|
return Object.freeze({
|
|
253
454
|
policyVersion: 2,
|
|
254
455
|
migrationCutoff: policy.migrationCutoff,
|
|
456
|
+
bindings: Object.freeze(bindings),
|
|
255
457
|
tables: Object.freeze(tables),
|
|
256
458
|
});
|
|
257
459
|
};
|
|
@@ -283,7 +485,67 @@ const pseudonym = (key, recipe, value) => {
|
|
|
283
485
|
return token.slice(0, recipe.maxLength);
|
|
284
486
|
};
|
|
285
487
|
|
|
286
|
-
const
|
|
488
|
+
const assertJsonBounds = (value, recipe, path) => {
|
|
489
|
+
let bytes = 0;
|
|
490
|
+
const jsonStringBytes = (string) => {
|
|
491
|
+
let size = 2;
|
|
492
|
+
for (let index = 0; index < string.length; index += 1) {
|
|
493
|
+
const code = string.charCodeAt(index);
|
|
494
|
+
if (code === 34 || code === 92) size += 2;
|
|
495
|
+
else if (code < 32) size += [8, 9, 10, 12, 13].includes(code) ? 2 : 6;
|
|
496
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
497
|
+
const next = string.charCodeAt(index + 1);
|
|
498
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
499
|
+
size += 4;
|
|
500
|
+
index += 1;
|
|
501
|
+
} else size += 6;
|
|
502
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) size += 6;
|
|
503
|
+
else if (code <= 0x7f) size += 1;
|
|
504
|
+
else if (code <= 0x7ff) size += 2;
|
|
505
|
+
else size += 3;
|
|
506
|
+
}
|
|
507
|
+
return size;
|
|
508
|
+
};
|
|
509
|
+
const visit = (current, depth) => {
|
|
510
|
+
if (depth > recipe.maximumDepth) {
|
|
511
|
+
throw new Error(`${path} exceeds maximumDepth ${recipe.maximumDepth}.`);
|
|
512
|
+
}
|
|
513
|
+
if (typeof current === "string") bytes += jsonStringBytes(current);
|
|
514
|
+
else if (current === null) bytes += 4;
|
|
515
|
+
else if (typeof current === "number" && !Number.isFinite(current))
|
|
516
|
+
throw new Error(`${path} contains an unsupported JSON value.`);
|
|
517
|
+
else if (["number", "boolean"].includes(typeof current))
|
|
518
|
+
bytes += String(current).length;
|
|
519
|
+
else if (Array.isArray(current)) {
|
|
520
|
+
if (
|
|
521
|
+
recipe.kind === "json-array" &&
|
|
522
|
+
current.length > recipe.maximumItems
|
|
523
|
+
) {
|
|
524
|
+
throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
|
|
525
|
+
}
|
|
526
|
+
bytes += 2 + Math.max(0, current.length - 1);
|
|
527
|
+
for (const item of current) visit(item, depth + 1);
|
|
528
|
+
} else if (isObject(current)) {
|
|
529
|
+
bytes += 2 + Math.max(0, Object.keys(current).length - 1);
|
|
530
|
+
for (const [key, nested] of Object.entries(current)) {
|
|
531
|
+
bytes += jsonStringBytes(key) + 1;
|
|
532
|
+
visit(nested, depth + 1);
|
|
533
|
+
}
|
|
534
|
+
} else throw new Error(`${path} contains an unsupported JSON value.`);
|
|
535
|
+
if (bytes > recipe.maximumBytes) {
|
|
536
|
+
throw new Error(`${path} exceeds maximumBytes ${recipe.maximumBytes}.`);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
visit(value, 1);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const executeDeclaration = ({
|
|
543
|
+
declaration,
|
|
544
|
+
value,
|
|
545
|
+
key,
|
|
546
|
+
path,
|
|
547
|
+
ownerApproved,
|
|
548
|
+
}) => {
|
|
287
549
|
if (declaration.action === "KEEP") return value;
|
|
288
550
|
if (declaration.action === "EXCLUDE") return undefined;
|
|
289
551
|
if (declaration.action === "REPLACE")
|
|
@@ -300,32 +562,88 @@ const executeDeclaration = ({ declaration, value, key, path }) => {
|
|
|
300
562
|
date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
|
|
301
563
|
return date.toISOString();
|
|
302
564
|
}
|
|
565
|
+
if (declaration.recipe.kind === "approved-owner") {
|
|
566
|
+
if (ownerApproved === undefined) {
|
|
567
|
+
throw new Error(`${path} has no reviewed owner condition.`);
|
|
568
|
+
}
|
|
569
|
+
return executeDeclaration({
|
|
570
|
+
declaration: ownerApproved
|
|
571
|
+
? declaration.recipe.approved
|
|
572
|
+
: declaration.recipe.otherwise,
|
|
573
|
+
value,
|
|
574
|
+
key,
|
|
575
|
+
path,
|
|
576
|
+
ownerApproved,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
303
579
|
if (value === null && declaration.recipe.allowNull) return null;
|
|
580
|
+
assertJsonBounds(value, declaration.recipe, path);
|
|
581
|
+
if (declaration.recipe.kind === "json-array") {
|
|
582
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
|
|
583
|
+
return value.flatMap((item, index) => {
|
|
584
|
+
const transformed = executeDeclaration({
|
|
585
|
+
declaration: declaration.recipe.items,
|
|
586
|
+
value: item,
|
|
587
|
+
key,
|
|
588
|
+
path: `${path}[${index}]`,
|
|
589
|
+
ownerApproved,
|
|
590
|
+
});
|
|
591
|
+
return transformed === undefined ? [] : [transformed];
|
|
592
|
+
});
|
|
593
|
+
}
|
|
304
594
|
if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
|
|
305
595
|
const actual = Object.keys(value).sort();
|
|
306
|
-
const expected = Object.keys(declaration.recipe.fields)
|
|
307
|
-
if (actual.
|
|
596
|
+
const expected = Object.keys(declaration.recipe.fields);
|
|
597
|
+
if (actual.some((field) => !expected.includes(field))) {
|
|
308
598
|
throw new Error(`${path} contains an unclassified JSON key.`);
|
|
309
599
|
}
|
|
600
|
+
const missing = expected.filter(
|
|
601
|
+
(field) =>
|
|
602
|
+
declaration.recipe.fields[field].required && !Object.hasOwn(value, field),
|
|
603
|
+
);
|
|
604
|
+
if (missing.length) throw new Error(`${path} is missing required JSON keys.`);
|
|
310
605
|
return Object.fromEntries(
|
|
311
606
|
Object.entries(declaration.recipe.fields).flatMap(([field, nested]) => {
|
|
607
|
+
if (!Object.hasOwn(value, field)) return [];
|
|
312
608
|
const transformed = executeDeclaration({
|
|
313
609
|
declaration: nested,
|
|
314
610
|
value: value[field],
|
|
315
611
|
key,
|
|
316
612
|
path: `${path}.${field}`,
|
|
613
|
+
ownerApproved,
|
|
317
614
|
});
|
|
318
615
|
return transformed === undefined ? [] : [[field, transformed]];
|
|
319
616
|
}),
|
|
320
617
|
);
|
|
321
618
|
};
|
|
322
619
|
|
|
323
|
-
export const createPrivacyEngine = ({
|
|
620
|
+
export const createPrivacyEngine = ({
|
|
621
|
+
policy,
|
|
622
|
+
key,
|
|
623
|
+
environment = process.env,
|
|
624
|
+
}) => {
|
|
324
625
|
const normalized = validateExecutablePrivacyPolicy(policy);
|
|
325
626
|
const secret = Buffer.isBuffer(key) ? key : Buffer.from(key ?? "");
|
|
326
627
|
if (secret.length < KEY_BYTES) {
|
|
327
628
|
throw new Error(`Privacy key must contain at least ${KEY_BYTES} bytes.`);
|
|
328
629
|
}
|
|
630
|
+
const bindingValues = new Map(
|
|
631
|
+
Object.entries(normalized.bindings).map(([name, binding]) => {
|
|
632
|
+
const value = environment[binding.environmentVariable];
|
|
633
|
+
if (typeof value !== "string" || !value) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`Privacy binding ${name} requires ${binding.environmentVariable}.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
const receipt = createHash("sha256").update(value).digest("hex");
|
|
639
|
+
if (receipt !== binding.approvedValueSha256) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Privacy binding ${name} does not match its review receipt.`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return [name, value];
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
329
647
|
const tables = new Map(
|
|
330
648
|
normalized.tables.map((table) => [
|
|
331
649
|
table.schema === "public" ? table.name : `${table.schema}.${table.name}`,
|
|
@@ -357,6 +675,15 @@ export const createPrivacyEngine = ({ policy, key }) => {
|
|
|
357
675
|
`Source table ${relation} contains an unclassified or missing column.`,
|
|
358
676
|
);
|
|
359
677
|
}
|
|
678
|
+
let ownerApproved;
|
|
679
|
+
if (table.ownerBinding) {
|
|
680
|
+
const owner = record.row[table.ownerBinding.column];
|
|
681
|
+
if (!["string", "number"].includes(typeof owner) || owner === "") {
|
|
682
|
+
throw new Error(`${relation} has an invalid owner binding value.`);
|
|
683
|
+
}
|
|
684
|
+
ownerApproved =
|
|
685
|
+
String(owner) === bindingValues.get(table.ownerBinding.binding);
|
|
686
|
+
}
|
|
360
687
|
const row = Object.fromEntries(
|
|
361
688
|
table.columns.flatMap((column) => {
|
|
362
689
|
const transformed = executeDeclaration({
|
|
@@ -364,6 +691,7 @@ export const createPrivacyEngine = ({ policy, key }) => {
|
|
|
364
691
|
value: record.row[column.name],
|
|
365
692
|
key: secret,
|
|
366
693
|
path: `${relation}.${column.name}`,
|
|
694
|
+
ownerApproved,
|
|
367
695
|
});
|
|
368
696
|
return transformed === undefined ? [] : [[column.name, transformed]];
|
|
369
697
|
}),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Preview or create the project-root Rehearsal configuration. */
|
|
2
|
+
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
findRehearsalConfigPath,
|
|
7
|
+
inspectDetectedProject,
|
|
8
|
+
loadRehearsalConfig,
|
|
9
|
+
renderDetectedConfig,
|
|
10
|
+
renderDetectedPostgresqlConfig,
|
|
11
|
+
} from "../project/configuration.mjs";
|
|
12
|
+
|
|
13
|
+
const findExistingConfig = async ({ projectRoot, configPath }) => {
|
|
14
|
+
try {
|
|
15
|
+
return await findRehearsalConfigPath({ projectRoot, configPath });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (
|
|
18
|
+
String(error?.message ?? error).startsWith("No Rehearsal configuration")
|
|
19
|
+
) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const runRehearsalInit = async ({ projectRoot, flags }) => {
|
|
27
|
+
const detected = await inspectDetectedProject({ projectRoot });
|
|
28
|
+
const destination = join(projectRoot, "rehearsal.config.mjs");
|
|
29
|
+
const existingPath = await findExistingConfig({
|
|
30
|
+
projectRoot,
|
|
31
|
+
configPath: flags.configPath,
|
|
32
|
+
});
|
|
33
|
+
if (existingPath) {
|
|
34
|
+
if (flags.write) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`A Rehearsal configuration already exists at ${relative(projectRoot, existingPath)}; Rehearsal will not overwrite it.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const loaded = await loadRehearsalConfig({
|
|
40
|
+
projectRoot,
|
|
41
|
+
configPath: flags.configPath,
|
|
42
|
+
});
|
|
43
|
+
const source = loaded.config.supabase
|
|
44
|
+
? renderDetectedConfig(detected, {
|
|
45
|
+
applicationUrl: loaded.config.runtime.applicationUrl,
|
|
46
|
+
ports: loaded.config.runtime.ports,
|
|
47
|
+
})
|
|
48
|
+
: renderDetectedPostgresqlConfig(detected, {
|
|
49
|
+
applicationUrl: loaded.config.runtime.applicationUrl,
|
|
50
|
+
databasePort: loaded.config.runtime.ports.database,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
mode: "current-template",
|
|
54
|
+
destination: relative(projectRoot, existingPath),
|
|
55
|
+
detected,
|
|
56
|
+
source,
|
|
57
|
+
nextAction:
|
|
58
|
+
"Your existing configuration was not changed. Compare it with this installed-release template to discover new optional keys.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const source = detected.hasSupabaseConfig
|
|
63
|
+
? renderDetectedConfig(detected)
|
|
64
|
+
: renderDetectedPostgresqlConfig(detected);
|
|
65
|
+
if (flags.write) {
|
|
66
|
+
await writeFile(destination, source, { flag: "wx", mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
mode: flags.write ? "written" : "preview",
|
|
70
|
+
destination: relative(projectRoot, destination),
|
|
71
|
+
detected,
|
|
72
|
+
source,
|
|
73
|
+
nextAction: flags.write
|
|
74
|
+
? "Review the generated safety settings and run rehearsal doctor."
|
|
75
|
+
: "Review this preview, then rerun rehearsal init --write to create it.",
|
|
76
|
+
};
|
|
77
|
+
};
|