@rehearsal-db/core 0.1.0-beta.1 → 0.1.0-beta.3
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 +55 -1
- package/README.md +30 -6
- package/docs/commands.md +25 -2
- package/docs/configuration.md +3 -1
- package/docs/getting-started.md +42 -13
- package/docs/production-source.md +1 -1
- package/docs/releasing.md +17 -36
- package/docs/sanitization.md +10 -0
- package/docs/troubleshooting.md +18 -2
- package/package.json +1 -1
- package/scripts/lib/rehearsal/baseline_builder.mjs +11 -6
- package/scripts/lib/rehearsal/baseline_preparation.mjs +201 -0
- package/scripts/lib/rehearsal/configuration.d.mts +14 -1
- package/scripts/lib/rehearsal/configuration.mjs +20 -10
- package/scripts/lib/rehearsal/diagnostics.mjs +7 -3
- package/scripts/lib/rehearsal/human_output.mjs +4 -0
- package/scripts/lib/rehearsal/plan.mjs +25 -2
- package/scripts/lib/rehearsal/sanitization_policy.mjs +110 -0
- package/scripts/lib/rehearsal/setup.mjs +347 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +12 -6
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +692 -16
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Inspect explicit local synthetic records and migration evidence, then
|
|
3
|
+
* create a fail-closed sanitization-policy draft for human review.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
import { access, mkdir, open, readFile } from "node:fs/promises";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { loadRehearsalConfig } from "./configuration.mjs";
|
|
11
|
+
import { buildMigrationLedgerInventory } from "./migration_history.mjs";
|
|
12
|
+
|
|
13
|
+
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
14
|
+
|
|
15
|
+
const resolveProjectInput = (projectRoot, value, label) => {
|
|
16
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
17
|
+
throw new Error(`${label} is required.`);
|
|
18
|
+
}
|
|
19
|
+
const path = resolve(projectRoot, value);
|
|
20
|
+
const owned = relative(projectRoot, path);
|
|
21
|
+
if (
|
|
22
|
+
owned === "" ||
|
|
23
|
+
owned === ".." ||
|
|
24
|
+
owned.startsWith(`..${sep}`) ||
|
|
25
|
+
isAbsolute(owned)
|
|
26
|
+
) {
|
|
27
|
+
throw new Error(`${label} must be a file inside the project root.`);
|
|
28
|
+
}
|
|
29
|
+
return path;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const pathExists = (path) =>
|
|
33
|
+
access(path)
|
|
34
|
+
.then(() => true)
|
|
35
|
+
.catch((error) => {
|
|
36
|
+
if (error?.code === "ENOENT") return false;
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const inspectSyntheticRecords = async (path) => {
|
|
41
|
+
const tables = new Map();
|
|
42
|
+
let rowCount = 0;
|
|
43
|
+
const lines = createInterface({
|
|
44
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
45
|
+
crlfDelay: Infinity,
|
|
46
|
+
});
|
|
47
|
+
for await (const line of lines) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
let record;
|
|
50
|
+
try {
|
|
51
|
+
record = JSON.parse(line);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error("Synthetic baseline input contains invalid NDJSON.", {
|
|
54
|
+
cause: error,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
!record ||
|
|
59
|
+
!identifierPattern.test(record.table ?? "") ||
|
|
60
|
+
!record.row ||
|
|
61
|
+
typeof record.row !== "object" ||
|
|
62
|
+
Array.isArray(record.row)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error("A synthetic baseline record has an invalid shape.");
|
|
65
|
+
}
|
|
66
|
+
const columns = Object.keys(record.row);
|
|
67
|
+
if (
|
|
68
|
+
columns.length === 0 ||
|
|
69
|
+
columns.some((column) => !identifierPattern.test(column))
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Synthetic baseline table ${record.table} has invalid or empty columns.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const table = tables.get(record.table) ?? {
|
|
76
|
+
name: record.table,
|
|
77
|
+
rowCount: 0,
|
|
78
|
+
columns: new Set(),
|
|
79
|
+
};
|
|
80
|
+
table.rowCount += 1;
|
|
81
|
+
for (const column of columns) table.columns.add(column);
|
|
82
|
+
tables.set(record.table, table);
|
|
83
|
+
rowCount += 1;
|
|
84
|
+
}
|
|
85
|
+
if (rowCount === 0) {
|
|
86
|
+
throw new Error("Synthetic baseline input contains no records.");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
rowCount,
|
|
90
|
+
tables: [...tables.values()]
|
|
91
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
92
|
+
.map((table) => ({
|
|
93
|
+
name: table.name,
|
|
94
|
+
rowCount: table.rowCount,
|
|
95
|
+
columns: [...table.columns].sort(),
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const renderPolicyDraft = ({ migrationCutoff, tables }) =>
|
|
101
|
+
`${JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
policyVersion: 1,
|
|
104
|
+
draft: true,
|
|
105
|
+
migrationCutoff,
|
|
106
|
+
tables: tables.map((table) => ({
|
|
107
|
+
name: table.name,
|
|
108
|
+
group: "synthetic",
|
|
109
|
+
sourceRows: "STREAM AND SANITIZE",
|
|
110
|
+
columns: table.columns.map((name) => ({
|
|
111
|
+
name,
|
|
112
|
+
action: "REVIEW REQUIRED",
|
|
113
|
+
generated: "REVIEW REQUIRED",
|
|
114
|
+
identity: "REVIEW REQUIRED",
|
|
115
|
+
foreignKey: "REVIEW REQUIRED",
|
|
116
|
+
})),
|
|
117
|
+
})),
|
|
118
|
+
},
|
|
119
|
+
null,
|
|
120
|
+
2,
|
|
121
|
+
)}\n`;
|
|
122
|
+
|
|
123
|
+
export const planBaselinePreparation = async ({
|
|
124
|
+
projectRoot = process.cwd(),
|
|
125
|
+
configPath,
|
|
126
|
+
recordsPath,
|
|
127
|
+
ledgerPath,
|
|
128
|
+
}) => {
|
|
129
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
130
|
+
const records = resolveProjectInput(
|
|
131
|
+
loaded.projectRoot,
|
|
132
|
+
recordsPath,
|
|
133
|
+
"Synthetic records path",
|
|
134
|
+
);
|
|
135
|
+
const ledger = resolveProjectInput(
|
|
136
|
+
loaded.projectRoot,
|
|
137
|
+
ledgerPath,
|
|
138
|
+
"Migration ledger path",
|
|
139
|
+
);
|
|
140
|
+
const [inspection, ledgerRows] = await Promise.all([
|
|
141
|
+
inspectSyntheticRecords(records),
|
|
142
|
+
readFile(ledger, "utf8").then(JSON.parse),
|
|
143
|
+
]);
|
|
144
|
+
const migrationHistory = buildMigrationLedgerInventory(
|
|
145
|
+
ledgerRows,
|
|
146
|
+
"Synthetic migration ledger",
|
|
147
|
+
);
|
|
148
|
+
const destination = loaded.paths.sanitizationPolicy;
|
|
149
|
+
if (await pathExists(destination)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Rehearsal baseline preparation will not overwrite ${relative(loaded.projectRoot, destination)}.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const migrationCutoff = migrationHistory.at(-1).version;
|
|
155
|
+
return {
|
|
156
|
+
projectRoot: loaded.projectRoot,
|
|
157
|
+
destination,
|
|
158
|
+
destinationRelative: relative(loaded.projectRoot, destination),
|
|
159
|
+
recordsPath: relative(loaded.projectRoot, records),
|
|
160
|
+
ledgerPath: relative(loaded.projectRoot, ledger),
|
|
161
|
+
migrationCutoff,
|
|
162
|
+
migrationCount: migrationHistory.length,
|
|
163
|
+
rowCount: inspection.rowCount,
|
|
164
|
+
tables: inspection.tables,
|
|
165
|
+
content: renderPolicyDraft({
|
|
166
|
+
migrationCutoff,
|
|
167
|
+
tables: inspection.tables,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const applyBaselinePreparation = async (plan) => {
|
|
173
|
+
if (await pathExists(plan.destination)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Rehearsal baseline preparation will not overwrite ${plan.destinationRelative}.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
await mkdir(dirname(plan.destination), { recursive: true, mode: 0o700 });
|
|
179
|
+
const handle = await open(plan.destination, "wx", 0o600);
|
|
180
|
+
try {
|
|
181
|
+
await handle.writeFile(plan.content);
|
|
182
|
+
await handle.sync();
|
|
183
|
+
} finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const summarizeBaselinePreparation = (plan, { mode }) => ({
|
|
189
|
+
mode,
|
|
190
|
+
destination: plan.destinationRelative,
|
|
191
|
+
recordsPath: plan.recordsPath,
|
|
192
|
+
ledgerPath: plan.ledgerPath,
|
|
193
|
+
migrationCutoff: plan.migrationCutoff,
|
|
194
|
+
migrationCount: plan.migrationCount,
|
|
195
|
+
rowCount: plan.rowCount,
|
|
196
|
+
tables: plan.tables,
|
|
197
|
+
nextAction:
|
|
198
|
+
mode === "written"
|
|
199
|
+
? `Review every REVIEW REQUIRED decision in ${plan.destinationRelative}, remove draft only after review, then create the baseline.`
|
|
200
|
+
: "Review this schema-only summary, then rerun with --write to create the draft.",
|
|
201
|
+
});
|
|
@@ -51,7 +51,13 @@ export declare const loadRehearsalConfig: (options?: {
|
|
|
51
51
|
export declare const inspectDetectedProject: (options?: {
|
|
52
52
|
projectRoot?: string;
|
|
53
53
|
}) => Promise<unknown>;
|
|
54
|
-
export declare const renderDetectedConfig: (
|
|
54
|
+
export declare const renderDetectedConfig: (
|
|
55
|
+
detected: unknown,
|
|
56
|
+
options?: {
|
|
57
|
+
applicationUrl?: string;
|
|
58
|
+
ports?: { api: number; database: number; studio: number };
|
|
59
|
+
},
|
|
60
|
+
) => string;
|
|
55
61
|
export declare const SANITIZATION_ACTIONS: Readonly<{
|
|
56
62
|
KEEP: "KEEP";
|
|
57
63
|
PSEUDONYMIZE: "PSEUDONYMIZE";
|
|
@@ -75,6 +81,13 @@ export declare const validateSanitizationCoverage: (options: {
|
|
|
75
81
|
columnCount: number;
|
|
76
82
|
tables: ReadonlyArray<Record<string, unknown>>;
|
|
77
83
|
}>;
|
|
84
|
+
export declare const validateRuntimeSanitizationPolicy: (
|
|
85
|
+
policy: unknown,
|
|
86
|
+
) => unknown;
|
|
87
|
+
export declare const readBoundRuntimeSanitizationPolicy: (options: {
|
|
88
|
+
bytes: Uint8Array | string;
|
|
89
|
+
expectedSha256: string;
|
|
90
|
+
}) => unknown;
|
|
78
91
|
export declare const applySanitizationAction: (options: {
|
|
79
92
|
action: string;
|
|
80
93
|
value: unknown;
|
|
@@ -13,7 +13,9 @@ export {
|
|
|
13
13
|
SANITIZATION_ACTIONS,
|
|
14
14
|
applySanitizationAction,
|
|
15
15
|
normalizeSanitizationAction,
|
|
16
|
+
readBoundRuntimeSanitizationPolicy,
|
|
16
17
|
validateSanitizationCoverage,
|
|
18
|
+
validateRuntimeSanitizationPolicy,
|
|
17
19
|
} from "./sanitization_policy.mjs";
|
|
18
20
|
|
|
19
21
|
export const REHEARSAL_CONFIG_VERSION = 1;
|
|
@@ -494,12 +496,15 @@ export const inspectDetectedProject = async ({
|
|
|
494
496
|
? "yarn"
|
|
495
497
|
: "npm";
|
|
496
498
|
const scripts = packageJson.scripts ?? {};
|
|
499
|
+
const normalizedProjectName = String(packageJson.name ?? basename(root))
|
|
500
|
+
.toLowerCase()
|
|
501
|
+
.replace(/[^a-z0-9-]+/gu, "-")
|
|
502
|
+
.replace(/^-|-$/gu, "");
|
|
503
|
+
const projectName =
|
|
504
|
+
normalizedProjectName.slice(0, 52).replace(/-$/u, "") ||
|
|
505
|
+
"rehearsal-project";
|
|
497
506
|
return {
|
|
498
|
-
projectName
|
|
499
|
-
String(packageJson.name ?? basename(root))
|
|
500
|
-
.toLowerCase()
|
|
501
|
-
.replace(/[^a-z0-9-]+/gu, "-")
|
|
502
|
-
.replace(/^-|-$/gu, "") || "rehearsal-project",
|
|
507
|
+
projectName,
|
|
503
508
|
packageManager,
|
|
504
509
|
hasSupabaseConfig: await hasPath("supabase/config.toml"),
|
|
505
510
|
hasMigrations: await hasPath("supabase/migrations"),
|
|
@@ -517,7 +522,12 @@ export const inspectDetectedProject = async ({
|
|
|
517
522
|
|
|
518
523
|
export const renderDetectedConfig = (
|
|
519
524
|
detected,
|
|
520
|
-
|
|
525
|
+
{
|
|
526
|
+
applicationUrl = "http://localhost:5175",
|
|
527
|
+
ports = { api: 58321, database: 58322, studio: 58323 },
|
|
528
|
+
} = {},
|
|
529
|
+
) => `// @ts-check
|
|
530
|
+
import { defineRehearsalConfig } from "@rehearsal-db/core";
|
|
521
531
|
|
|
522
532
|
export default defineRehearsalConfig({
|
|
523
533
|
schemaVersion: 1,
|
|
@@ -538,11 +548,11 @@ export default defineRehearsalConfig({
|
|
|
538
548
|
environmentFile: ".rehearsal/runtime.env",
|
|
539
549
|
},
|
|
540
550
|
runtime: {
|
|
541
|
-
applicationUrl:
|
|
551
|
+
applicationUrl: ${JSON.stringify(applicationUrl)},
|
|
542
552
|
projectId: "${detected.projectName}-rehearsal",
|
|
543
|
-
apiPort:
|
|
544
|
-
databasePort:
|
|
545
|
-
studioPort:
|
|
553
|
+
apiPort: ${ports.api},
|
|
554
|
+
databasePort: ${ports.database},
|
|
555
|
+
studioPort: ${ports.studio},
|
|
546
556
|
},
|
|
547
557
|
safety: {
|
|
548
558
|
allowedHosts: ["127.0.0.1", "::1", "localhost"],
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
|
|
9
9
|
export const REHEARSAL_RESULT_VERSION = 1;
|
|
10
|
-
export const REHEARSAL_VERSION = "0.1.0-beta.
|
|
10
|
+
export const REHEARSAL_VERSION = "0.1.0-beta.3";
|
|
11
11
|
|
|
12
12
|
export const REHEARSAL_EXIT_CODES = Object.freeze({
|
|
13
13
|
success: 0,
|
|
@@ -96,7 +96,9 @@ const inferCategory = (error) => {
|
|
|
96
96
|
if (/candidate|migration history diverges/iu.test(message)) {
|
|
97
97
|
return "migration_candidate_failure";
|
|
98
98
|
}
|
|
99
|
-
if (/baseline|artifact|generation/iu.test(message))
|
|
99
|
+
if (/baseline|artifact|generation|sanitization policy/iu.test(message)) {
|
|
100
|
+
return "baseline_invalid";
|
|
101
|
+
}
|
|
100
102
|
if (/migration|ledger|replay/iu.test(message)) {
|
|
101
103
|
return "migration_verification_failure";
|
|
102
104
|
}
|
|
@@ -106,7 +108,9 @@ const inferCategory = (error) => {
|
|
|
106
108
|
if (/loopback|hosted|unsafe|credential|outbound/iu.test(message)) {
|
|
107
109
|
return "unsafe_environment";
|
|
108
110
|
}
|
|
109
|
-
if (
|
|
111
|
+
if (
|
|
112
|
+
/docker|supabase|runtime|command|enoent|node\.js|ports?\b/iu.test(message)
|
|
113
|
+
) {
|
|
110
114
|
return "runtime_dependency_failure";
|
|
111
115
|
}
|
|
112
116
|
return "internal_failure";
|
|
@@ -19,6 +19,10 @@ import { createCandidateMigrationReceipt } from "./runtime_restore.mjs";
|
|
|
19
19
|
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
20
20
|
import { REHEARSAL_VERSION } from "./diagnostics.mjs";
|
|
21
21
|
import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
22
|
+
import {
|
|
23
|
+
readBoundRuntimeSanitizationPolicy,
|
|
24
|
+
validateRuntimeSanitizationPolicy,
|
|
25
|
+
} from "./sanitization_policy.mjs";
|
|
22
26
|
|
|
23
27
|
const commandAvailable = (command, args = ["--version"]) => {
|
|
24
28
|
const result = spawnSync(command, args, {
|
|
@@ -155,8 +159,15 @@ const assertConfiguredCommand = async ({ command, projectRoot }) => {
|
|
|
155
159
|
|
|
156
160
|
const loadPlanInputs = async (options = {}) => {
|
|
157
161
|
const loaded = await loadRehearsalConfig(options);
|
|
158
|
-
const baseline = await
|
|
159
|
-
|
|
162
|
+
const [baseline, policyBytes] = await Promise.all([
|
|
163
|
+
verifyActiveBaseline({
|
|
164
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
165
|
+
}),
|
|
166
|
+
readFile(loaded.paths.sanitizationPolicy),
|
|
167
|
+
]);
|
|
168
|
+
readBoundRuntimeSanitizationPolicy({
|
|
169
|
+
bytes: policyBytes,
|
|
170
|
+
expectedSha256: baseline.sanitizationPolicySha256,
|
|
160
171
|
});
|
|
161
172
|
const currentFiles = await readMigrationFileInventory(
|
|
162
173
|
new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}/`)),
|
|
@@ -412,6 +423,18 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
412
423
|
remediation:
|
|
413
424
|
"Correct the missing project path in the Rehearsal configuration.",
|
|
414
425
|
},
|
|
426
|
+
{
|
|
427
|
+
id: "sanitization-policy",
|
|
428
|
+
label: "Reviewed sanitization policy",
|
|
429
|
+
run: async () => {
|
|
430
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
431
|
+
JSON.parse(await readFile(paths.sanitizationPolicy, "utf8")),
|
|
432
|
+
);
|
|
433
|
+
return `${policy.tables.length} table classifications reviewed through ${policy.migrationCutoff}`;
|
|
434
|
+
},
|
|
435
|
+
remediation:
|
|
436
|
+
"Complete every REVIEW REQUIRED decision, remove draft only after review, and retry.",
|
|
437
|
+
},
|
|
415
438
|
{
|
|
416
439
|
id: "runtime-isolation",
|
|
417
440
|
label: "Runtime target isolation",
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Projects own the policy, schema inventory, pseudonym keys, and derivation logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
6
8
|
export const SANITIZATION_ACTIONS = Object.freeze({
|
|
7
9
|
KEEP: "KEEP",
|
|
8
10
|
PSEUDONYMIZE: "PSEUDONYMIZE",
|
|
@@ -44,6 +46,114 @@ export const normalizeSanitizationAction = (action) => {
|
|
|
44
46
|
return normalized;
|
|
45
47
|
};
|
|
46
48
|
|
|
49
|
+
export const validateRuntimeSanitizationPolicy = (policy) => {
|
|
50
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
51
|
+
throw new Error("A Rehearsal sanitization policy object is required.");
|
|
52
|
+
}
|
|
53
|
+
if (policy.draft === true) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"The sanitization policy is still a REVIEW REQUIRED draft.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (policy.policyVersion !== 1) {
|
|
59
|
+
throw new Error("The sanitization policy must use policyVersion 1.");
|
|
60
|
+
}
|
|
61
|
+
if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"The sanitization policy must declare a timestamp migrationCutoff.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
|
|
67
|
+
throw new Error("The sanitization policy does not list tables.");
|
|
68
|
+
}
|
|
69
|
+
const tables = new Set();
|
|
70
|
+
for (const table of policy.tables) {
|
|
71
|
+
const tableName = assertIdentifier(
|
|
72
|
+
table?.name,
|
|
73
|
+
"Sanitization policy table name",
|
|
74
|
+
);
|
|
75
|
+
if (tables.has(tableName)) {
|
|
76
|
+
throw new Error(`Sanitization policy duplicates table ${tableName}.`);
|
|
77
|
+
}
|
|
78
|
+
tables.add(tableName);
|
|
79
|
+
if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Sanitization policy table ${tableName} has an invalid sourceRows decision.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(table.columns) || table.columns.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Sanitization policy table ${tableName} must list its columns.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const columns = new Set();
|
|
90
|
+
for (const column of table.columns) {
|
|
91
|
+
const columnName = assertIdentifier(
|
|
92
|
+
column?.name,
|
|
93
|
+
`Sanitization policy column in ${tableName}`,
|
|
94
|
+
);
|
|
95
|
+
if (columns.has(columnName)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Sanitization policy duplicates column ${tableName}.${columnName}.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
columns.add(columnName);
|
|
101
|
+
normalizeSanitizationAction(column.action);
|
|
102
|
+
if (!["ALWAYS", "NEVER"].includes(column.generated)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Sanitization policy column ${tableName}.${columnName} must classify generated as ALWAYS or NEVER.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!["YES", "NO"].includes(column.identity)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Sanitization policy column ${tableName}.${columnName} must classify identity as YES or NO.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (column.foreignKey !== null) {
|
|
113
|
+
if (
|
|
114
|
+
!column.foreignKey ||
|
|
115
|
+
typeof column.foreignKey !== "object" ||
|
|
116
|
+
Array.isArray(column.foreignKey)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Sanitization policy column ${tableName}.${columnName} has an invalid foreignKey decision.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
assertIdentifier(
|
|
123
|
+
column.foreignKey.schema,
|
|
124
|
+
`Foreign-key schema for ${tableName}.${columnName}`,
|
|
125
|
+
);
|
|
126
|
+
assertIdentifier(
|
|
127
|
+
column.foreignKey.table,
|
|
128
|
+
`Foreign-key table for ${tableName}.${columnName}`,
|
|
129
|
+
);
|
|
130
|
+
assertIdentifier(
|
|
131
|
+
column.foreignKey.column,
|
|
132
|
+
`Foreign-key column for ${tableName}.${columnName}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return policy;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const readBoundRuntimeSanitizationPolicy = ({
|
|
141
|
+
bytes,
|
|
142
|
+
expectedSha256,
|
|
143
|
+
}) => {
|
|
144
|
+
const source = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
145
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
146
|
+
JSON.parse(source.toString("utf8")),
|
|
147
|
+
);
|
|
148
|
+
const actualSha256 = createHash("sha256").update(source).digest("hex");
|
|
149
|
+
if (actualSha256 !== expectedSha256) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"The active sanitization policy does not match the reviewed baseline policy checksum.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return policy;
|
|
155
|
+
};
|
|
156
|
+
|
|
47
157
|
const indexTables = (tables, label) => {
|
|
48
158
|
if (!Array.isArray(tables)) throw new Error(`${label} must be an array.`);
|
|
49
159
|
const index = new Map();
|