@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,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build the immutable, audit-only Rehearsal execution plan and readiness
|
|
3
|
+
* report from project configuration and verified artifacts. Do not run directly;
|
|
4
|
+
* this module is reusable script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { createConnection } from "node:net";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { performance } from "node:perf_hooks";
|
|
12
|
+
import { verifyActiveBaseline } from "../baseline/artifact.mjs";
|
|
13
|
+
import { applySupabaseAuthenticationProviders, inspectSupabaseAuthenticationProviderTransition, } from "../identity/provider_configuration.mjs";
|
|
14
|
+
import { inspectDetectedProject, loadRehearsalConfig, } from "../project/configuration.mjs";
|
|
15
|
+
import { createCandidateMigrationReceipt } from "./restore.mjs";
|
|
16
|
+
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
17
|
+
import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
|
|
18
|
+
import { inspectRehearsalNodeRuntime, REHEARSAL_NODE_LABEL, } from "../shared/node_runtime.mjs";
|
|
19
|
+
import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
20
|
+
import { readBoundRuntimeSanitizationPolicy, validateRuntimeSanitizationPolicy, } from "../baseline/sanitization_policy.mjs";
|
|
21
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
22
|
+
const hasErrorCode = (error, code) => typeof error === "object" &&
|
|
23
|
+
error !== null &&
|
|
24
|
+
"code" in error &&
|
|
25
|
+
error.code === code;
|
|
26
|
+
const commandAvailable = (command, args = ["--version"]) => {
|
|
27
|
+
const result = spawnSync(command, args, {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
env: Object.fromEntries(["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]])),
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
available: result.status === 0,
|
|
34
|
+
version: result.status === 0
|
|
35
|
+
? String(result.stdout || result.stderr).trim()
|
|
36
|
+
: null,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
const isPortAvailable = (port) => new Promise((resolveAvailability) => {
|
|
40
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
41
|
+
socket.unref();
|
|
42
|
+
socket.setTimeout(500);
|
|
43
|
+
socket.once("connect", () => {
|
|
44
|
+
socket.destroy();
|
|
45
|
+
resolveAvailability(false);
|
|
46
|
+
});
|
|
47
|
+
socket.once("error", () => resolveAvailability(true));
|
|
48
|
+
socket.once("timeout", () => {
|
|
49
|
+
socket.destroy();
|
|
50
|
+
resolveAvailability(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const readRuntimeReceipt = async (runtimeWorkdir) => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(await readFile(join(runtimeWorkdir, "candidate-receipt.json"), "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
59
|
+
return null;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const verifyPath = async (path, kind) => {
|
|
64
|
+
const details = await stat(path);
|
|
65
|
+
if (kind === "directory" && !details.isDirectory()) {
|
|
66
|
+
throw new Error(`Expected a directory at ${path}.`);
|
|
67
|
+
}
|
|
68
|
+
if (kind === "file" && !details.isFile()) {
|
|
69
|
+
throw new Error(`Expected a file at ${path}.`);
|
|
70
|
+
}
|
|
71
|
+
return details;
|
|
72
|
+
};
|
|
73
|
+
const resultCheck = async ({ id, label, run, remediation, }) => {
|
|
74
|
+
const startedAt = performance.now();
|
|
75
|
+
try {
|
|
76
|
+
const detail = await run();
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
label,
|
|
80
|
+
status: "pass",
|
|
81
|
+
detail,
|
|
82
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
label,
|
|
89
|
+
status: "fail",
|
|
90
|
+
detail: errorMessage(error),
|
|
91
|
+
remediation,
|
|
92
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const assertRuntimeConfigMatches = async ({ config, paths, }) => {
|
|
97
|
+
if (config.runtime.target === "postgresql") {
|
|
98
|
+
if (paths.rehearsalConfig !== null || paths.supabaseWorkdir !== null) {
|
|
99
|
+
throw new Error("The PostgreSQL target unexpectedly resolved Supabase paths.");
|
|
100
|
+
}
|
|
101
|
+
if (!config.postgresql) {
|
|
102
|
+
throw new Error("The PostgreSQL target is missing its configuration.");
|
|
103
|
+
}
|
|
104
|
+
return `isolated PostgreSQL image ${config.postgresql.image} and a loopback database port are configured`;
|
|
105
|
+
}
|
|
106
|
+
if (!paths.rehearsalConfig) {
|
|
107
|
+
throw new Error("The Supabase target is missing its runtime configuration.");
|
|
108
|
+
}
|
|
109
|
+
const source = await readFile(paths.rehearsalConfig, "utf8");
|
|
110
|
+
const expected = [
|
|
111
|
+
["project_id", config.runtime.projectId],
|
|
112
|
+
["port", config.runtime.ports.api],
|
|
113
|
+
["port", config.runtime.ports.database],
|
|
114
|
+
["port", config.runtime.ports.studio],
|
|
115
|
+
];
|
|
116
|
+
for (const [, value] of expected) {
|
|
117
|
+
if (!source.includes(String(value))) {
|
|
118
|
+
throw new Error(`Local Supabase config does not declare ${value}.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (/\blinked\s*=\s*true\b/iu.test(source)) {
|
|
122
|
+
throw new Error("Local Supabase config unexpectedly enables a linked target.");
|
|
123
|
+
}
|
|
124
|
+
return "isolated Supabase project and ports match the project contract";
|
|
125
|
+
};
|
|
126
|
+
const assertBaselinePermissions = async ({ paths, }) => {
|
|
127
|
+
const details = await stat(paths.dataPath);
|
|
128
|
+
if ((details.mode & 0o077) !== 0) {
|
|
129
|
+
throw new Error("Sanitized baseline data is readable by group or other users.");
|
|
130
|
+
}
|
|
131
|
+
return "baseline data is owner-only";
|
|
132
|
+
};
|
|
133
|
+
const baselineCreatedAt = (generationId) => {
|
|
134
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/u.exec(generationId);
|
|
135
|
+
return match
|
|
136
|
+
? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`
|
|
137
|
+
: null;
|
|
138
|
+
};
|
|
139
|
+
const assertConfiguredCommand = async ({ command, projectRoot, }) => {
|
|
140
|
+
const match = /^(npm|pnpm|yarn)\s+(?:run\s+)?([^\s]+)/u.exec(command);
|
|
141
|
+
if (!match)
|
|
142
|
+
return `declared command: ${command}`;
|
|
143
|
+
const packageJson = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf8"));
|
|
144
|
+
const scriptName = match[2];
|
|
145
|
+
if (!packageJson.scripts?.[scriptName]) {
|
|
146
|
+
throw new Error(`package.json has no ${scriptName} script.`);
|
|
147
|
+
}
|
|
148
|
+
return `package script ${scriptName} exists`;
|
|
149
|
+
};
|
|
150
|
+
const loadPlanInputs = async (options = {}) => {
|
|
151
|
+
const loaded = await loadRehearsalConfig(options);
|
|
152
|
+
const [baseline, policyBytes] = await Promise.all([
|
|
153
|
+
verifyActiveBaseline({
|
|
154
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
155
|
+
}),
|
|
156
|
+
readFile(loaded.paths.sanitizationPolicy),
|
|
157
|
+
]);
|
|
158
|
+
readBoundRuntimeSanitizationPolicy({
|
|
159
|
+
bytes: policyBytes,
|
|
160
|
+
expectedSha256: baseline.sanitizationPolicySha256,
|
|
161
|
+
});
|
|
162
|
+
const currentFiles = await readMigrationFileInventory(new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}/`)));
|
|
163
|
+
const candidateReceipt = createCandidateMigrationReceipt({
|
|
164
|
+
baselineManifest: baseline,
|
|
165
|
+
currentFiles,
|
|
166
|
+
});
|
|
167
|
+
const runtimeReceipt = await readRuntimeReceipt(loaded.paths.runtimeWorkdir);
|
|
168
|
+
const appliedCandidateDigest = runtimeReceipt?.baselineGenerationId === baseline.generationId &&
|
|
169
|
+
runtimeReceipt?.candidateSha256 === candidateReceipt.candidateSha256
|
|
170
|
+
? candidateReceipt.candidateSha256
|
|
171
|
+
: null;
|
|
172
|
+
const baselineNames = new Set(Object.keys(baseline.migrations));
|
|
173
|
+
const candidateNames = new Set(candidateReceipt.candidates.map((entry) => entry.filename));
|
|
174
|
+
const migrations = currentFiles.map((entry) => ({
|
|
175
|
+
version: entry.version,
|
|
176
|
+
name: entry.name,
|
|
177
|
+
filename: entry.filename,
|
|
178
|
+
sha256: entry.fileSha256,
|
|
179
|
+
status: baselineNames.has(entry.filename)
|
|
180
|
+
? "represented_by_baseline"
|
|
181
|
+
: candidateNames.has(entry.filename) && appliedCandidateDigest
|
|
182
|
+
? "applied_to_current_runtime"
|
|
183
|
+
: "candidate",
|
|
184
|
+
}));
|
|
185
|
+
return {
|
|
186
|
+
...loaded,
|
|
187
|
+
baseline,
|
|
188
|
+
currentFiles,
|
|
189
|
+
candidateReceipt,
|
|
190
|
+
migrations,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
export const buildRehearsalPlan = async (options = {}) => {
|
|
194
|
+
const inputs = await loadPlanInputs(options);
|
|
195
|
+
const { baseline, candidateReceipt, config, configPath, migrations, paths } = inputs;
|
|
196
|
+
return {
|
|
197
|
+
config: {
|
|
198
|
+
schemaVersion: config.schemaVersion,
|
|
199
|
+
path: configPath,
|
|
200
|
+
project: config.project.name,
|
|
201
|
+
},
|
|
202
|
+
environment: {
|
|
203
|
+
target: config.runtime.target,
|
|
204
|
+
kind: `isolated_local_${config.runtime.target}`,
|
|
205
|
+
applicationUrl: config.runtime.applicationUrl,
|
|
206
|
+
projectId: config.runtime.projectId,
|
|
207
|
+
ports: config.runtime.ports,
|
|
208
|
+
hostedAccess: config.safety.hostedAccess,
|
|
209
|
+
outboundNetwork: config.safety.outboundNetwork,
|
|
210
|
+
authenticationProviders: config.safety.authenticationProviders,
|
|
211
|
+
authenticationCallback: config.supabase?.authentication
|
|
212
|
+
? `http://127.0.0.1:${config.runtime.ports.api}/auth/v1/callback`
|
|
213
|
+
: null,
|
|
214
|
+
barriers: [
|
|
215
|
+
"versioned configuration accepts loopback hosts only",
|
|
216
|
+
`runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
|
|
217
|
+
"child processes receive an allowlisted environment",
|
|
218
|
+
"configuration accepts only loopback runtime URLs and quarantines common hosted credentials",
|
|
219
|
+
...(config.safety.authenticationProviders.length
|
|
220
|
+
? [
|
|
221
|
+
`external identity is limited to: ${config.safety.authenticationProviders.join(", ")}`,
|
|
222
|
+
]
|
|
223
|
+
: []),
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
baseline: {
|
|
227
|
+
rehearsalVersion: REHEARSAL_VERSION,
|
|
228
|
+
formatVersion: baseline.formatVersion,
|
|
229
|
+
generationId: baseline.generationId,
|
|
230
|
+
createdAt: baselineCreatedAt(baseline.generationId),
|
|
231
|
+
migrationCutoff: baseline.migrationCutoff,
|
|
232
|
+
migrationHistorySha256: baseline.migrationHistorySha256 ?? null,
|
|
233
|
+
sanitizationPolicySha256: baseline.sanitizationPolicySha256,
|
|
234
|
+
dataSha256: baseline.files["sanitized-data.ndjson"].sha256,
|
|
235
|
+
tableCount: Object.keys(baseline.tableCounts).length,
|
|
236
|
+
rowCount: baseline.rowCount,
|
|
237
|
+
migrationCount: Object.keys(baseline.migrations).length,
|
|
238
|
+
verification: "checksums_verified",
|
|
239
|
+
},
|
|
240
|
+
migrations: {
|
|
241
|
+
representedCount: migrations.filter((entry) => entry.status === "represented_by_baseline").length,
|
|
242
|
+
candidateCount: candidateReceipt.candidates.length,
|
|
243
|
+
candidateSha256: candidateReceipt.candidateSha256,
|
|
244
|
+
candidates: candidateReceipt.candidates.map((entry) => ({
|
|
245
|
+
filename: entry.filename,
|
|
246
|
+
sha256: entry.fileSha256,
|
|
247
|
+
})),
|
|
248
|
+
},
|
|
249
|
+
execution: [
|
|
250
|
+
"verify the sanitized baseline and immutable migration prefix",
|
|
251
|
+
"replace the disposable local Rehearsal runtime",
|
|
252
|
+
"restore and verify exact table counts plus foreign keys",
|
|
253
|
+
...(candidateReceipt.candidates.length
|
|
254
|
+
? ["apply only the exact candidate migration digest"]
|
|
255
|
+
: ["confirm that no candidate migrations are pending"]),
|
|
256
|
+
"verify the resulting migration ledger and database state",
|
|
257
|
+
`run application proof: ${config.application.proofCommand}`,
|
|
258
|
+
],
|
|
259
|
+
guarantee: config.safety.authenticationProviders.length
|
|
260
|
+
? "No hosted application or database resources will be contacted; the declared identity-provider exchange creates state only in local Auth."
|
|
261
|
+
: "No production resources will be contacted.",
|
|
262
|
+
paths: {
|
|
263
|
+
artifactDirectory: paths.artifactDirectory,
|
|
264
|
+
migrationDirectory: paths.migrationDirectory,
|
|
265
|
+
sanitizationPolicy: paths.sanitizationPolicy,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
export const inspectRehearsalBaseline = async (options = {}) => {
|
|
270
|
+
const plan = await buildRehearsalPlan(options);
|
|
271
|
+
return {
|
|
272
|
+
...plan.baseline,
|
|
273
|
+
project: plan.config.project,
|
|
274
|
+
provenance: {
|
|
275
|
+
configVersion: plan.config.schemaVersion,
|
|
276
|
+
migrationCutoff: plan.baseline.migrationCutoff,
|
|
277
|
+
sanitizationPolicySha256: plan.baseline.sanitizationPolicySha256,
|
|
278
|
+
},
|
|
279
|
+
privacy: {
|
|
280
|
+
sourceRowsExposed: false,
|
|
281
|
+
sensitiveValuesIncludedInReport: false,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
export const inspectRehearsalMigrations = async (options = {}) => {
|
|
286
|
+
const inputs = await loadPlanInputs(options);
|
|
287
|
+
return {
|
|
288
|
+
baselineGenerationId: inputs.baseline.generationId,
|
|
289
|
+
baselineCutoff: inputs.baseline.migrationCutoff,
|
|
290
|
+
candidateSha256: inputs.candidateReceipt.candidateSha256,
|
|
291
|
+
migrations: inputs.migrations,
|
|
292
|
+
definitions: {
|
|
293
|
+
represented_by_baseline: "Exact filename and SHA-256 are part of the verified baseline prefix.",
|
|
294
|
+
applied_to_current_runtime: "The exact current candidate digest has a verified local runtime receipt.",
|
|
295
|
+
candidate: "The file follows the exact baseline prefix and has not been proven in the current runtime.",
|
|
296
|
+
modified: "A baseline-prefix filename or digest differs; planning fails before this report can be trusted.",
|
|
297
|
+
invalid: "The migration filename, ordering, uniqueness, or source shape is invalid; planning fails closed.",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
export const runRehearsalDoctor = async (options = {}) => {
|
|
302
|
+
let loaded;
|
|
303
|
+
try {
|
|
304
|
+
loaded = await loadRehearsalConfig(options);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return {
|
|
308
|
+
state: "NOT READY",
|
|
309
|
+
checks: [
|
|
310
|
+
{
|
|
311
|
+
id: "configuration",
|
|
312
|
+
label: "Versioned configuration",
|
|
313
|
+
status: "fail",
|
|
314
|
+
detail: errorMessage(error),
|
|
315
|
+
remediation: "Run rehearsal init, review the preview, then write a valid configuration.",
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const { config, paths, projectRoot } = loaded;
|
|
321
|
+
const requirePostgresqlConfig = () => {
|
|
322
|
+
if (!config.postgresql) {
|
|
323
|
+
throw new Error("The PostgreSQL target is missing its configuration.");
|
|
324
|
+
}
|
|
325
|
+
return config.postgresql;
|
|
326
|
+
};
|
|
327
|
+
const requireSupabaseConfig = () => {
|
|
328
|
+
if (!config.supabase) {
|
|
329
|
+
throw new Error("The Supabase target is missing its configuration.");
|
|
330
|
+
}
|
|
331
|
+
return config.supabase;
|
|
332
|
+
};
|
|
333
|
+
const supabaseAuthentication = config.supabase?.authentication ?? null;
|
|
334
|
+
const nodeRuntime = inspectRehearsalNodeRuntime();
|
|
335
|
+
const checkDefinitions = [
|
|
336
|
+
{
|
|
337
|
+
id: "node",
|
|
338
|
+
label: "Node.js compatibility",
|
|
339
|
+
run: async () => {
|
|
340
|
+
if (!nodeRuntime.supported)
|
|
341
|
+
throw new Error(`Node ${process.versions.node} is unsupported.`);
|
|
342
|
+
return `Node ${process.versions.node}`;
|
|
343
|
+
},
|
|
344
|
+
remediation: `Use ${REHEARSAL_NODE_LABEL}. Node.js 24 LTS is recommended.`,
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "operating-system",
|
|
348
|
+
label: "Operating-system support",
|
|
349
|
+
run: async () => {
|
|
350
|
+
if (!["darwin", "linux"].includes(process.platform)) {
|
|
351
|
+
throw new Error(`${process.platform} is not supported by the version-1 contract.`);
|
|
352
|
+
}
|
|
353
|
+
return `${process.platform} is in the supported matrix`;
|
|
354
|
+
},
|
|
355
|
+
remediation: "Use macOS or Linux; WSL and native Windows require separate proof before support.",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: "package-manager",
|
|
359
|
+
label: "Package-manager support",
|
|
360
|
+
run: async () => {
|
|
361
|
+
const detected = await inspectDetectedProject({ projectRoot });
|
|
362
|
+
if (detected.packageManager !== "npm") {
|
|
363
|
+
throw new Error(`${detected.packageManager} is detected but not yet supported.`);
|
|
364
|
+
}
|
|
365
|
+
return "npm";
|
|
366
|
+
},
|
|
367
|
+
remediation: "Use npm for the initial Rehearsal contract.",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
id: config.runtime.target === "supabase"
|
|
371
|
+
? "supabase-cli"
|
|
372
|
+
: "postgresql-image",
|
|
373
|
+
label: config.runtime.target === "supabase"
|
|
374
|
+
? "Supabase CLI"
|
|
375
|
+
: "PostgreSQL image",
|
|
376
|
+
run: async () => {
|
|
377
|
+
if (config.runtime.target === "supabase") {
|
|
378
|
+
const result = commandAvailable("supabase");
|
|
379
|
+
if (!result.available)
|
|
380
|
+
throw new Error("Supabase CLI is unavailable.");
|
|
381
|
+
return result.version;
|
|
382
|
+
}
|
|
383
|
+
const postgresql = requirePostgresqlConfig();
|
|
384
|
+
const result = commandAvailable("docker", [
|
|
385
|
+
"image",
|
|
386
|
+
"inspect",
|
|
387
|
+
postgresql.image,
|
|
388
|
+
]);
|
|
389
|
+
if (!result.available) {
|
|
390
|
+
throw new Error(`PostgreSQL image ${postgresql.image} is unavailable locally.`);
|
|
391
|
+
}
|
|
392
|
+
return postgresql.image;
|
|
393
|
+
},
|
|
394
|
+
remediation: config.runtime.target === "supabase"
|
|
395
|
+
? "Install the Supabase CLI in the project and retry."
|
|
396
|
+
: `Pull and review ${requirePostgresqlConfig().image}, then retry.`,
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: "docker",
|
|
400
|
+
label: "Docker-compatible runtime",
|
|
401
|
+
run: async () => {
|
|
402
|
+
const result = commandAvailable("docker", [
|
|
403
|
+
"info",
|
|
404
|
+
"--format",
|
|
405
|
+
"{{.ServerVersion}}",
|
|
406
|
+
]);
|
|
407
|
+
if (!result.available)
|
|
408
|
+
throw new Error("Docker-compatible runtime is unavailable.");
|
|
409
|
+
return `Docker ${result.version}`;
|
|
410
|
+
},
|
|
411
|
+
remediation: "Start Docker or Colima, then retry.",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: "paths",
|
|
415
|
+
label: "Project-owned paths",
|
|
416
|
+
run: async () => {
|
|
417
|
+
await Promise.all([
|
|
418
|
+
verifyPath(paths.migrationDirectory, "directory"),
|
|
419
|
+
...(paths.rehearsalConfig
|
|
420
|
+
? [verifyPath(paths.rehearsalConfig, "file")]
|
|
421
|
+
: []),
|
|
422
|
+
verifyPath(paths.sanitizationPolicy, "file"),
|
|
423
|
+
...(paths.runtimeAdapter
|
|
424
|
+
? [verifyPath(paths.runtimeAdapter, "file")]
|
|
425
|
+
: []),
|
|
426
|
+
]);
|
|
427
|
+
return config.runtime.target === "supabase"
|
|
428
|
+
? "migrations, local runtime config, and sanitization policy exist"
|
|
429
|
+
: "migrations and sanitization policy exist";
|
|
430
|
+
},
|
|
431
|
+
remediation: "Correct the missing project path in the Rehearsal configuration.",
|
|
432
|
+
},
|
|
433
|
+
...(supabaseAuthentication
|
|
434
|
+
? [
|
|
435
|
+
{
|
|
436
|
+
id: "authentication-runtime-config",
|
|
437
|
+
label: "Local authentication configuration",
|
|
438
|
+
run: async () => {
|
|
439
|
+
const supabase = requireSupabaseConfig();
|
|
440
|
+
const apiPort = config.runtime.ports.api;
|
|
441
|
+
if (!paths.rehearsalConfig || apiPort === undefined) {
|
|
442
|
+
throw new Error("The Supabase authentication runtime paths are incomplete.");
|
|
443
|
+
}
|
|
444
|
+
const source = await readFile(paths.rehearsalConfig, "utf8");
|
|
445
|
+
const transition = inspectSupabaseAuthenticationProviderTransition({
|
|
446
|
+
source,
|
|
447
|
+
authentication: supabase.authentication,
|
|
448
|
+
});
|
|
449
|
+
applySupabaseAuthenticationProviders({
|
|
450
|
+
source,
|
|
451
|
+
authentication: supabase.authentication,
|
|
452
|
+
apiPort,
|
|
453
|
+
});
|
|
454
|
+
return transition.replacedProviders.length
|
|
455
|
+
? `compatible legacy ${transition.replacedProviders.join(", ")} provider configuration will be replaced only in the disposable runtime copy`
|
|
456
|
+
: "declarative provider configuration can be generated without changing the tracked template";
|
|
457
|
+
},
|
|
458
|
+
remediation: "Review the named provider section and make skipNonceCheck or emailOptional match explicitly in config.supabase.authentication; otherwise keep the documented manual provider configuration.",
|
|
459
|
+
},
|
|
460
|
+
]
|
|
461
|
+
: []),
|
|
462
|
+
{
|
|
463
|
+
id: "sanitization-policy",
|
|
464
|
+
label: "Reviewed sanitization policy",
|
|
465
|
+
run: async () => {
|
|
466
|
+
const policy = validateRuntimeSanitizationPolicy(JSON.parse(await readFile(paths.sanitizationPolicy, "utf8")));
|
|
467
|
+
return `${policy.tables.length} table classifications reviewed through ${policy.migrationCutoff}`;
|
|
468
|
+
},
|
|
469
|
+
remediation: "Complete every REVIEW REQUIRED decision, remove draft only after review, and retry.",
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
id: "runtime-isolation",
|
|
473
|
+
label: "Runtime target isolation",
|
|
474
|
+
run: () => assertRuntimeConfigMatches({ config, paths }),
|
|
475
|
+
remediation: "Use a dedicated unlinked local Supabase config whose ports match rehearsal.config.",
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
id: "service-environment",
|
|
479
|
+
label: "Local service credentials",
|
|
480
|
+
run: async () => {
|
|
481
|
+
if (config.runtime.target === "postgresql") {
|
|
482
|
+
return "no local service credentials required";
|
|
483
|
+
}
|
|
484
|
+
const supabase = requireSupabaseConfig();
|
|
485
|
+
const environment = await readRehearsalServiceEnvironment({
|
|
486
|
+
path: paths.serviceEnvironment,
|
|
487
|
+
keys: supabase.serviceEnvironmentVariables,
|
|
488
|
+
blockedKeys: config.safety.blockedEnvironmentVariables,
|
|
489
|
+
});
|
|
490
|
+
const count = Object.keys(environment).length;
|
|
491
|
+
return count
|
|
492
|
+
? `${count} explicitly allowlisted local service variables loaded`
|
|
493
|
+
: "no local service credentials required";
|
|
494
|
+
},
|
|
495
|
+
remediation: "Create the configured ignored service environment file, add only its allowlisted values, and chmod it to 600.",
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
id: "runtime-ports",
|
|
499
|
+
label: "Dedicated local ports",
|
|
500
|
+
run: async () => {
|
|
501
|
+
const availability = await Promise.all(Object.entries(config.runtime.ports).map(async ([name, port]) => ({
|
|
502
|
+
name,
|
|
503
|
+
port,
|
|
504
|
+
available: await isPortAvailable(port),
|
|
505
|
+
})));
|
|
506
|
+
const occupied = availability.filter((entry) => !entry.available);
|
|
507
|
+
if (occupied.length) {
|
|
508
|
+
if (config.runtime.target === "postgresql") {
|
|
509
|
+
const status = spawnSync("docker", [
|
|
510
|
+
"ps",
|
|
511
|
+
"--filter",
|
|
512
|
+
`label=com.rehearsal-db.project=${config.runtime.projectId}`,
|
|
513
|
+
"--format",
|
|
514
|
+
"{{.Ports}}",
|
|
515
|
+
], {
|
|
516
|
+
cwd: projectRoot,
|
|
517
|
+
encoding: "utf8",
|
|
518
|
+
env: Object.fromEntries(["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) => process.env[key] === undefined
|
|
519
|
+
? []
|
|
520
|
+
: [[key, process.env[key]]])),
|
|
521
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
522
|
+
});
|
|
523
|
+
if (status.status !== 0 ||
|
|
524
|
+
!String(status.stdout).includes(`127.0.0.1:${config.runtime.ports.database}->5432/tcp`)) {
|
|
525
|
+
throw new Error(`Configured database port is occupied by an unverified process: database=${config.runtime.ports.database}.`);
|
|
526
|
+
}
|
|
527
|
+
return "occupied only by the configured local Rehearsal runtime";
|
|
528
|
+
}
|
|
529
|
+
const supabase = requireSupabaseConfig();
|
|
530
|
+
const serviceEnvironment = await readRehearsalServiceEnvironment({
|
|
531
|
+
path: paths.serviceEnvironment,
|
|
532
|
+
keys: supabase.serviceEnvironmentVariables,
|
|
533
|
+
blockedKeys: config.safety.blockedEnvironmentVariables,
|
|
534
|
+
});
|
|
535
|
+
const status = spawnSync("supabase", ["status", "-o", "env", "--workdir", paths.runtimeWorkdir], {
|
|
536
|
+
cwd: projectRoot,
|
|
537
|
+
encoding: "utf8",
|
|
538
|
+
env: {
|
|
539
|
+
...Object.fromEntries(["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) => process.env[key] === undefined
|
|
540
|
+
? []
|
|
541
|
+
: [[key, process.env[key]]])),
|
|
542
|
+
...serviceEnvironment,
|
|
543
|
+
},
|
|
544
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
545
|
+
});
|
|
546
|
+
if (status.status !== 0 ||
|
|
547
|
+
!String(status.stdout).includes(`API_URL="http://127.0.0.1:${config.runtime.ports.api}"`)) {
|
|
548
|
+
throw new Error(`Configured ports are occupied by an unverified process: ${occupied.map(({ name, port }) => `${name}=${port}`).join(", ")}.`);
|
|
549
|
+
}
|
|
550
|
+
return "occupied only by the configured local Rehearsal runtime";
|
|
551
|
+
}
|
|
552
|
+
return "all configured ports are available";
|
|
553
|
+
},
|
|
554
|
+
remediation: "Stop the conflicting process or choose a unique local Rehearsal port set.",
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
id: "baseline",
|
|
558
|
+
label: "Baseline integrity",
|
|
559
|
+
run: async () => {
|
|
560
|
+
const baseline = await verifyActiveBaseline({
|
|
561
|
+
artifactRoot: paths.artifactDirectory,
|
|
562
|
+
});
|
|
563
|
+
return `${baseline.generationId}; ${baseline.rowCount} rows; checksums verified`;
|
|
564
|
+
},
|
|
565
|
+
remediation: "Create or restore a verified sanitized baseline before running Rehearsal.",
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: "baseline-permissions",
|
|
569
|
+
label: "Baseline file permissions",
|
|
570
|
+
run: async () => {
|
|
571
|
+
const baseline = await verifyActiveBaseline({
|
|
572
|
+
artifactRoot: paths.artifactDirectory,
|
|
573
|
+
});
|
|
574
|
+
return assertBaselinePermissions({
|
|
575
|
+
paths: {
|
|
576
|
+
dataPath: join(paths.artifactDirectory, "generations", baseline.generationId, "sanitized-data.ndjson"),
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
remediation: "Restrict baseline files to the current user, then rebuild the artifact.",
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
id: "migration-history",
|
|
584
|
+
label: "Migration history",
|
|
585
|
+
run: async () => {
|
|
586
|
+
const plan = await buildRehearsalPlan(options);
|
|
587
|
+
return `${plan.migrations.representedCount} represented; ${plan.migrations.candidateCount} candidates; digest ${plan.migrations.candidateSha256}`;
|
|
588
|
+
},
|
|
589
|
+
remediation: "Restore the exact migration prefix or create a new reviewed baseline.",
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
id: "application-command",
|
|
593
|
+
label: "Application commands",
|
|
594
|
+
run: async () => {
|
|
595
|
+
const start = await assertConfiguredCommand({
|
|
596
|
+
command: config.application.startCommand,
|
|
597
|
+
projectRoot,
|
|
598
|
+
});
|
|
599
|
+
const proof = await assertConfiguredCommand({
|
|
600
|
+
command: config.application.proofCommand,
|
|
601
|
+
projectRoot,
|
|
602
|
+
});
|
|
603
|
+
return `${start}; ${proof}`;
|
|
604
|
+
},
|
|
605
|
+
remediation: "Declare existing project commands for application launch and proof.",
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
id: "safety-policy",
|
|
609
|
+
label: "Hosted target configuration",
|
|
610
|
+
run: async () => {
|
|
611
|
+
if (config.safety.hostedAccess !== "disabled" ||
|
|
612
|
+
config.safety.outboundNetwork !== "deny") {
|
|
613
|
+
throw new Error("The safety policy is not fail closed.");
|
|
614
|
+
}
|
|
615
|
+
return config.safety.authenticationProviders.length
|
|
616
|
+
? `hosted database targets disabled; hosted credentials quarantined; local identity providers: ${config.safety.authenticationProviders.join(", ")}`
|
|
617
|
+
: "hosted database targets disabled; common hosted credentials quarantined";
|
|
618
|
+
},
|
|
619
|
+
remediation: "Restore the version-1 fail-closed safety policy.",
|
|
620
|
+
},
|
|
621
|
+
];
|
|
622
|
+
const checks = [];
|
|
623
|
+
for (const definition of checkDefinitions) {
|
|
624
|
+
checks.push(await resultCheck(definition));
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
state: checks.every((check) => check.status === "pass")
|
|
628
|
+
? "READY"
|
|
629
|
+
: "NOT READY",
|
|
630
|
+
checks,
|
|
631
|
+
ambientHostedVariables: {
|
|
632
|
+
presentButQuarantined: config.safety.blockedEnvironmentVariables.filter((key) => Boolean(process.env[key]?.trim())),
|
|
633
|
+
note: "Blocked ambient values are not inherited by Rehearsal child processes.",
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
};
|