@rehearsal-db/core 0.1.0-beta.1

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