@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9

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 (64) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +15 -3
  3. package/README.md +31 -13
  4. package/docs/README.md +29 -0
  5. package/docs/adapters.md +15 -6
  6. package/docs/architecture.md +58 -0
  7. package/docs/baselines.md +22 -1
  8. package/docs/commands.md +81 -14
  9. package/docs/configuration.md +144 -11
  10. package/docs/getting-started.md +12 -2
  11. package/docs/glossary.md +4 -0
  12. package/docs/production-source.md +176 -33
  13. package/docs/roadmap.md +30 -28
  14. package/docs/runtime-policies.md +191 -0
  15. package/docs/sanitization.md +24 -8
  16. package/docs/security-model.md +29 -8
  17. package/docs/standalone-workflow.md +107 -0
  18. package/docs/troubleshooting.md +8 -0
  19. package/package.json +25 -22
  20. package/scripts/runtime/manage_database.mjs +18 -0
  21. package/src/README.md +17 -0
  22. package/src/application/session.mjs +313 -0
  23. package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
  24. package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
  25. package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
  26. package/src/baseline/privacy_engine.mjs +413 -0
  27. package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
  28. package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
  29. package/src/cli/arguments.mjs +120 -0
  30. package/src/cli/guided.mjs +807 -0
  31. package/src/cli/rehearsal.mjs +933 -0
  32. package/src/cli/renderers.mjs +584 -0
  33. package/src/cli/runtime_commands.mjs +553 -0
  34. package/src/cli/source_commands.mjs +326 -0
  35. package/src/cli/terminal.mjs +275 -0
  36. package/src/identity/claim.mjs +975 -0
  37. package/src/identity/storage.mjs +165 -0
  38. package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
  39. package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
  40. package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
  41. package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
  42. package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
  43. package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
  44. package/src/runtime/policy.mjs +438 -0
  45. package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
  46. package/src/runtime/topology.mjs +177 -0
  47. package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
  48. package/src/shared/operation_guard.mjs +162 -0
  49. package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
  50. package/src/source/access.mjs +578 -0
  51. package/src/source/asset_transfer.mjs +177 -0
  52. package/src/source/baseline.mjs +446 -0
  53. package/src/source/postgresql_access.mjs +480 -0
  54. package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
  55. package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
  56. package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
  57. package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
  58. package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
  59. package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
  60. /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
  61. /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
  62. /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
  63. /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
  64. /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
@@ -0,0 +1,165 @@
1
+ /** Copy and verify local Supabase Storage objects during an identity claim. */
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { assertLoopbackUrl } from "../shared/process_environment.mjs";
5
+
6
+ const BUCKET = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
7
+ const MAXIMUM_VERIFICATION_BYTES = 100 * 1024 * 1024;
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+
10
+ const objectPath = (value) => {
11
+ if (
12
+ typeof value !== "string" ||
13
+ !value ||
14
+ value.startsWith("/") ||
15
+ value.split("/").some((part) => !part || part === "." || part === "..")
16
+ ) {
17
+ throw new Error("A Storage identity-claim path is unsafe.");
18
+ }
19
+ return value.split("/").map(encodeURIComponent).join("/");
20
+ };
21
+
22
+ const readVerifiedObject = async ({
23
+ fetchImpl,
24
+ baseUrl,
25
+ headers,
26
+ bucket,
27
+ path,
28
+ }) => {
29
+ const response = await fetchImpl(
30
+ `${baseUrl}/storage/v1/object/${encodeURIComponent(bucket)}/${objectPath(path)}?rehearsal=${Date.now()}`,
31
+ {
32
+ headers,
33
+ cache: "no-store",
34
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
35
+ },
36
+ );
37
+ if (!response.ok) {
38
+ throw new Error(
39
+ `Supabase Storage could not read an identity-claim object (HTTP ${response.status}).`,
40
+ );
41
+ }
42
+ const declaredBytes = Number(response.headers.get("content-length"));
43
+ if (
44
+ Number.isFinite(declaredBytes) &&
45
+ declaredBytes > MAXIMUM_VERIFICATION_BYTES
46
+ ) {
47
+ throw new Error(
48
+ "A Storage identity-claim object exceeds the 100 MiB verification limit.",
49
+ );
50
+ }
51
+ const bytes = Buffer.from(await response.arrayBuffer());
52
+ if (bytes.length > MAXIMUM_VERIFICATION_BYTES) {
53
+ throw new Error(
54
+ "A Storage identity-claim object exceeds the 100 MiB verification limit.",
55
+ );
56
+ }
57
+ return Object.freeze({
58
+ bytes: bytes.length,
59
+ sha256: createHash("sha256").update(bytes).digest("hex"),
60
+ });
61
+ };
62
+
63
+ export const createSupabaseStorageTransfer = ({
64
+ url,
65
+ serviceRoleKey,
66
+ fetchImpl = globalThis.fetch,
67
+ }) => {
68
+ assertLoopbackUrl("Rehearsal Supabase Storage", url);
69
+ if (typeof serviceRoleKey !== "string" || !serviceRoleKey) {
70
+ throw new Error(
71
+ "The local runtime environment has no Storage service key.",
72
+ );
73
+ }
74
+ if (typeof fetchImpl !== "function") {
75
+ throw new Error("A Fetch implementation is required for Storage transfer.");
76
+ }
77
+ const baseUrl = url.replace(/\/$/u, "");
78
+ const headers = Object.freeze({
79
+ apikey: serviceRoleKey,
80
+ authorization: `Bearer ${serviceRoleKey}`,
81
+ });
82
+ const removePaths = async ({ bucket, paths }) => {
83
+ if (!BUCKET.test(bucket ?? "") || !Array.isArray(paths) || !paths.length) {
84
+ throw new Error("A Storage identity-claim removal is invalid.");
85
+ }
86
+ paths.forEach(objectPath);
87
+ const response = await fetchImpl(
88
+ `${baseUrl}/storage/v1/object/${encodeURIComponent(bucket)}`,
89
+ {
90
+ method: "DELETE",
91
+ headers: { ...headers, "content-type": "application/json" },
92
+ body: JSON.stringify({ prefixes: paths }),
93
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
94
+ },
95
+ );
96
+ if (!response.ok) {
97
+ throw new Error(
98
+ `Supabase Storage could not remove identity-claim objects (HTTP ${response.status}).`,
99
+ );
100
+ }
101
+ };
102
+ return Object.freeze({
103
+ async copyAndVerify({ bucket, source, destination }) {
104
+ if (!BUCKET.test(bucket ?? "")) {
105
+ throw new Error("A Storage identity-claim bucket is invalid.");
106
+ }
107
+ objectPath(source);
108
+ objectPath(destination);
109
+ if (source === destination) {
110
+ throw new Error("A Storage identity-claim copy must change the path.");
111
+ }
112
+ const sourceReceipt = await readVerifiedObject({
113
+ fetchImpl,
114
+ baseUrl,
115
+ headers,
116
+ bucket,
117
+ path: source,
118
+ });
119
+ try {
120
+ const response = await fetchImpl(`${baseUrl}/storage/v1/object/copy`, {
121
+ method: "POST",
122
+ headers: { ...headers, "content-type": "application/json" },
123
+ body: JSON.stringify({
124
+ bucketId: bucket,
125
+ sourceKey: source,
126
+ destinationKey: destination,
127
+ }),
128
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
129
+ });
130
+ if (!response.ok) {
131
+ throw new Error(
132
+ `Supabase Storage could not copy an identity-claim object (HTTP ${response.status}).`,
133
+ );
134
+ }
135
+ const destinationReceipt = await readVerifiedObject({
136
+ fetchImpl,
137
+ baseUrl,
138
+ headers,
139
+ bucket,
140
+ path: destination,
141
+ });
142
+ if (
143
+ sourceReceipt.bytes !== destinationReceipt.bytes ||
144
+ sourceReceipt.sha256 !== destinationReceipt.sha256
145
+ ) {
146
+ throw new Error(
147
+ "Supabase Storage copied an identity object with a different byte digest.",
148
+ );
149
+ }
150
+ return destinationReceipt;
151
+ } catch (error) {
152
+ try {
153
+ await removePaths({ bucket, paths: [destination] });
154
+ } catch (cleanupError) {
155
+ throw new AggregateError(
156
+ [error, cleanupError],
157
+ "Storage identity-copy verification failed and destination cleanup needs attention.",
158
+ );
159
+ }
160
+ throw error;
161
+ }
162
+ },
163
+ remove: removePaths,
164
+ });
165
+ };
@@ -28,10 +28,44 @@ export interface RehearsalConfig {
28
28
  cleanup?: {
29
29
  retainBaselineGenerations?: number;
30
30
  };
31
+ preparation?: {
32
+ sourcePolicy: string;
33
+ privacyKey: string;
34
+ batchRows?: number;
35
+ maximumRows?: number;
36
+ maximumBytes?: number;
37
+ diskHeadroomBytes?: number;
38
+ };
39
+ runtimePolicy?: string;
40
+ identityPolicy?: string;
41
+ dependentTargets?: Array<{
42
+ name: string;
43
+ configPath: string;
44
+ prepareCommand?: string;
45
+ }>;
31
46
  application: {
32
47
  startCommand: string;
33
48
  proofCommand: string;
34
49
  environmentFile?: string;
50
+ environmentVariables?: Record<string, string>;
51
+ readiness?: {
52
+ url: string;
53
+ expectedStatus: number;
54
+ timeoutSeconds?: number;
55
+ };
56
+ httpProofs?: Array<{
57
+ name: string;
58
+ kind: "positive" | "negative";
59
+ url: string;
60
+ method?: string;
61
+ expectedStatus: number;
62
+ json?: {
63
+ path: string[];
64
+ equals?: unknown;
65
+ notEquals?: unknown;
66
+ minimumItems?: number;
67
+ };
68
+ }>;
35
69
  runtimeAdapter?: string;
36
70
  };
37
71
  runtime: {
@@ -7,7 +7,7 @@
7
7
  import { access, readFile, stat } from "node:fs/promises";
8
8
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
- import { resolveRuntimeTarget } from "../runtime/runtime_target.mjs";
10
+ import { resolveRuntimeTarget } from "../targets/target.mjs";
11
11
 
12
12
  export {
13
13
  EXCLUDED_VALUE,
@@ -17,7 +17,7 @@ export {
17
17
  readBoundRuntimeSanitizationPolicy,
18
18
  validateSanitizationCoverage,
19
19
  validateRuntimeSanitizationPolicy,
20
- } from "./sanitization_policy.mjs";
20
+ } from "../baseline/sanitization_policy.mjs";
21
21
 
22
22
  export const REHEARSAL_CONFIG_VERSION = 1;
23
23
 
@@ -59,6 +59,8 @@ const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}$/u;
59
59
  const DATABASE_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]{0,62}$/u;
60
60
  const POSTGRES_IMAGE_PATTERN = /^postgres:\d+(?:\.\d+)?-alpine$/u;
61
61
  const SAFE_COMMAND_PATTERN = /^[^\n\r\0]+$/u;
62
+ const ENVIRONMENT_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/u;
63
+ const ENVIRONMENT_REFERENCE_PATTERN = /^[a-z0-9][a-z0-9-]*:[A-Z][A-Z0-9_]*$/u;
62
64
 
63
65
  const isPlainObject = (value) =>
64
66
  value !== null &&
@@ -138,6 +140,33 @@ const assertStringArray = (value, path) => {
138
140
  return [...new Set(value.map((entry) => entry.trim()))];
139
141
  };
140
142
 
143
+ const normalizeApplicationEnvironmentMappings = (value = {}) => {
144
+ const mappings = assertPlainObject(
145
+ value,
146
+ "config.application.environmentVariables",
147
+ );
148
+ return Object.freeze(
149
+ Object.fromEntries(
150
+ Object.entries(mappings).map(([key, reference]) => {
151
+ if (!ENVIRONMENT_KEY_PATTERN.test(key)) {
152
+ throw new Error(
153
+ `config.application.environmentVariables.${key} is not a safe environment variable name.`,
154
+ );
155
+ }
156
+ if (
157
+ typeof reference !== "string" ||
158
+ !ENVIRONMENT_REFERENCE_PATTERN.test(reference)
159
+ ) {
160
+ throw new Error(
161
+ `config.application.environmentVariables.${key} must reference target:VARIABLE, such as primary:DATABASE_URL.`,
162
+ );
163
+ }
164
+ return [key, reference];
165
+ }),
166
+ ),
167
+ );
168
+ };
169
+
141
170
  const assertLoopbackUrl = (value, path, allowedHosts) => {
142
171
  let parsed;
143
172
  try {
@@ -169,6 +198,10 @@ const normalizeConfig = (input) => {
169
198
  "baseline",
170
199
  "containerRuntime",
171
200
  "cleanup",
201
+ "preparation",
202
+ "runtimePolicy",
203
+ "identityPolicy",
204
+ "dependentTargets",
172
205
  "application",
173
206
  "runtime",
174
207
  "safety",
@@ -248,12 +281,94 @@ const normalizeConfig = (input) => {
248
281
  );
249
282
  const cleanup = assertPlainObject(root.cleanup ?? {}, "config.cleanup");
250
283
  assertKnownKeys(cleanup, ["retainBaselineGenerations"], "config.cleanup");
284
+ const preparation =
285
+ root.preparation === undefined
286
+ ? null
287
+ : assertPlainObject(root.preparation, "config.preparation");
288
+ if (preparation) {
289
+ assertKnownKeys(
290
+ preparation,
291
+ [
292
+ "sourcePolicy",
293
+ "privacyKey",
294
+ "batchRows",
295
+ "maximumRows",
296
+ "maximumBytes",
297
+ "diskHeadroomBytes",
298
+ ],
299
+ "config.preparation",
300
+ );
301
+ }
302
+ const dependentTargets = root.dependentTargets ?? [];
303
+ if (!Array.isArray(dependentTargets)) {
304
+ throw new Error("config.dependentTargets must be an array.");
305
+ }
306
+ const normalizedDependentTargets = dependentTargets.map((entry, index) => {
307
+ const path = `config.dependentTargets[${index}]`;
308
+ const target = assertPlainObject(entry, path);
309
+ assertKnownKeys(target, ["name", "configPath", "prepareCommand"], path);
310
+ const name = assertNonEmptyString(target.name, `${path}.name`);
311
+ if (!IDENTIFIER_PATTERN.test(name)) {
312
+ throw new Error(
313
+ `${path}.name must contain only lowercase letters, digits, and hyphens.`,
314
+ );
315
+ }
316
+ return Object.freeze({
317
+ name,
318
+ configPath: assertRelativePath(target.configPath, `${path}.configPath`),
319
+ prepareCommand:
320
+ target.prepareCommand === undefined
321
+ ? null
322
+ : assertCommand(target.prepareCommand, `${path}.prepareCommand`),
323
+ });
324
+ });
325
+ if (
326
+ new Set(normalizedDependentTargets.map((target) => target.name)).size !==
327
+ normalizedDependentTargets.length
328
+ ) {
329
+ throw new Error("config.dependentTargets names must be unique.");
330
+ }
251
331
  const application = assertPlainObject(root.application, "config.application");
252
332
  assertKnownKeys(
253
333
  application,
254
- ["startCommand", "proofCommand", "environmentFile", "runtimeAdapter"],
334
+ [
335
+ "startCommand",
336
+ "proofCommand",
337
+ "environmentFile",
338
+ "environmentVariables",
339
+ "readiness",
340
+ "httpProofs",
341
+ "runtimeAdapter",
342
+ ],
255
343
  "config.application",
256
344
  );
345
+ const readiness =
346
+ application.readiness === undefined
347
+ ? null
348
+ : assertPlainObject(
349
+ application.readiness,
350
+ "config.application.readiness",
351
+ );
352
+ if (readiness) {
353
+ assertKnownKeys(
354
+ readiness,
355
+ ["url", "expectedStatus", "timeoutSeconds"],
356
+ "config.application.readiness",
357
+ );
358
+ }
359
+ const httpProofs = application.httpProofs ?? [];
360
+ if (!Array.isArray(httpProofs)) {
361
+ throw new Error("config.application.httpProofs must be an array.");
362
+ }
363
+ if (
364
+ httpProofs.length > 0 &&
365
+ (!httpProofs.some((proof) => proof?.kind === "positive") ||
366
+ !httpProofs.some((proof) => proof?.kind === "negative"))
367
+ ) {
368
+ throw new Error(
369
+ "config.application.httpProofs must include at least one positive and one negative check.",
370
+ );
371
+ }
257
372
  const runtime = assertPlainObject(root.runtime ?? {}, "config.runtime");
258
373
  assertKnownKeys(
259
374
  runtime,
@@ -469,6 +584,43 @@ const normalizeConfig = (input) => {
469
584
  "config.cleanup.retainBaselineGenerations",
470
585
  ),
471
586
  }),
587
+ preparation: preparation
588
+ ? Object.freeze({
589
+ sourcePolicy: assertRelativePath(
590
+ preparation.sourcePolicy,
591
+ "config.preparation.sourcePolicy",
592
+ ),
593
+ privacyKey: assertRelativePath(
594
+ preparation.privacyKey,
595
+ "config.preparation.privacyKey",
596
+ ),
597
+ batchRows: assertPositiveInteger(
598
+ preparation.batchRows ?? 500,
599
+ "config.preparation.batchRows",
600
+ ),
601
+ maximumRows: assertPositiveInteger(
602
+ preparation.maximumRows ?? 1_000_000,
603
+ "config.preparation.maximumRows",
604
+ ),
605
+ maximumBytes: assertPositiveInteger(
606
+ preparation.maximumBytes ?? 2 * 1024 * 1024 * 1024,
607
+ "config.preparation.maximumBytes",
608
+ ),
609
+ diskHeadroomBytes: assertPositiveInteger(
610
+ preparation.diskHeadroomBytes ?? 64 * 1024 * 1024,
611
+ "config.preparation.diskHeadroomBytes",
612
+ ),
613
+ })
614
+ : null,
615
+ runtimePolicy:
616
+ root.runtimePolicy === undefined
617
+ ? null
618
+ : assertRelativePath(root.runtimePolicy, "config.runtimePolicy"),
619
+ identityPolicy:
620
+ root.identityPolicy === undefined
621
+ ? null
622
+ : assertRelativePath(root.identityPolicy, "config.identityPolicy"),
623
+ dependentTargets: Object.freeze(normalizedDependentTargets),
472
624
  application: Object.freeze({
473
625
  startCommand: assertCommand(
474
626
  application.startCommand,
@@ -482,6 +634,117 @@ const normalizeConfig = (input) => {
482
634
  application.environmentFile ?? ".rehearsal/runtime.env",
483
635
  "config.application.environmentFile",
484
636
  ),
637
+ environmentVariables: normalizeApplicationEnvironmentMappings(
638
+ application.environmentVariables,
639
+ ),
640
+ readiness: readiness
641
+ ? Object.freeze({
642
+ url: assertLoopbackUrl(
643
+ readiness.url,
644
+ "config.application.readiness.url",
645
+ allowedHosts,
646
+ ),
647
+ expectedStatus: (() => {
648
+ if (
649
+ !Number.isSafeInteger(readiness.expectedStatus) ||
650
+ readiness.expectedStatus < 100 ||
651
+ readiness.expectedStatus > 599
652
+ ) {
653
+ throw new Error(
654
+ "config.application.readiness.expectedStatus must be an HTTP status.",
655
+ );
656
+ }
657
+ return readiness.expectedStatus;
658
+ })(),
659
+ timeoutSeconds: assertPositiveInteger(
660
+ readiness.timeoutSeconds ?? 30,
661
+ "config.application.readiness.timeoutSeconds",
662
+ ),
663
+ })
664
+ : null,
665
+ httpProofs: Object.freeze(
666
+ httpProofs.map((proof, index) => {
667
+ const path = `config.application.httpProofs[${index}]`;
668
+ const value = assertPlainObject(proof, path);
669
+ assertKnownKeys(
670
+ value,
671
+ ["name", "kind", "url", "method", "expectedStatus", "json"],
672
+ path,
673
+ );
674
+ if (!["positive", "negative"].includes(value.kind)) {
675
+ throw new Error(`${path}.kind must be positive or negative.`);
676
+ }
677
+ if (
678
+ !Number.isSafeInteger(value.expectedStatus) ||
679
+ value.expectedStatus < 100 ||
680
+ value.expectedStatus > 599
681
+ ) {
682
+ throw new Error(`${path}.expectedStatus must be an HTTP status.`);
683
+ }
684
+ const method = assertNonEmptyString(
685
+ value.method ?? "GET",
686
+ `${path}.method`,
687
+ ).toUpperCase();
688
+ if (!["GET", "HEAD"].includes(method)) {
689
+ throw new Error(`${path}.method must be GET or HEAD.`);
690
+ }
691
+ let json = null;
692
+ if (value.json !== undefined) {
693
+ const assertion = assertPlainObject(value.json, `${path}.json`);
694
+ assertKnownKeys(
695
+ assertion,
696
+ ["path", "equals", "notEquals", "minimumItems"],
697
+ `${path}.json`,
698
+ );
699
+ if (
700
+ !Array.isArray(assertion.path) ||
701
+ assertion.path.length === 0 ||
702
+ assertion.path.some(
703
+ (segment) => typeof segment !== "string" || !segment,
704
+ )
705
+ ) {
706
+ throw new Error(
707
+ `${path}.json.path must be a non-empty string array.`,
708
+ );
709
+ }
710
+ const assertionCount = [
711
+ Object.hasOwn(assertion, "equals"),
712
+ Object.hasOwn(assertion, "notEquals"),
713
+ assertion.minimumItems !== undefined,
714
+ ].filter(Boolean).length;
715
+ if (assertionCount !== 1) {
716
+ throw new Error(
717
+ `${path}.json must declare exactly one assertion.`,
718
+ );
719
+ }
720
+ json = Object.freeze({
721
+ path: Object.freeze([...assertion.path]),
722
+ ...(Object.hasOwn(assertion, "equals")
723
+ ? { equals: assertion.equals }
724
+ : {}),
725
+ ...(Object.hasOwn(assertion, "notEquals")
726
+ ? { notEquals: assertion.notEquals }
727
+ : {}),
728
+ ...(assertion.minimumItems !== undefined
729
+ ? {
730
+ minimumItems: assertPositiveInteger(
731
+ assertion.minimumItems,
732
+ `${path}.json.minimumItems`,
733
+ ),
734
+ }
735
+ : {}),
736
+ });
737
+ }
738
+ return Object.freeze({
739
+ name: assertNonEmptyString(value.name, `${path}.name`),
740
+ kind: value.kind,
741
+ url: assertLoopbackUrl(value.url, `${path}.url`, allowedHosts),
742
+ method,
743
+ expectedStatus: value.expectedStatus,
744
+ json,
745
+ });
746
+ }),
747
+ ),
485
748
  runtimeAdapter:
486
749
  application.runtimeAdapter === undefined
487
750
  ? null
@@ -589,6 +852,18 @@ export const loadRehearsalConfig = async ({
589
852
  ? resolveOwnedPath(config.supabase.serviceEnvironmentFile)
590
853
  : null,
591
854
  sanitizationPolicy: resolveOwnedPath(config.baseline.sanitizationPolicy),
855
+ sourcePolicy: config.preparation
856
+ ? resolveOwnedPath(config.preparation.sourcePolicy)
857
+ : null,
858
+ privacyKey: config.preparation
859
+ ? resolveOwnedPath(config.preparation.privacyKey)
860
+ : null,
861
+ runtimePolicy: config.runtimePolicy
862
+ ? resolveOwnedPath(config.runtimePolicy)
863
+ : null,
864
+ identityPolicy: config.identityPolicy
865
+ ? resolveOwnedPath(config.identityPolicy)
866
+ : null,
592
867
  supabaseWorkdir: config.supabase
593
868
  ? resolveOwnedPath(config.supabase.workdir)
594
869
  : null,
@@ -620,6 +895,21 @@ export const loadRehearsalConfig = async ({
620
895
  "config.application.environmentFile must resolve to a file inside config.baseline.artifactDirectory.",
621
896
  );
622
897
  }
898
+ if (paths.privacyKey) {
899
+ const privacyKeyRelativePath = relative(
900
+ paths.artifactDirectory,
901
+ paths.privacyKey,
902
+ );
903
+ if (
904
+ !privacyKeyRelativePath ||
905
+ privacyKeyRelativePath.startsWith("..") ||
906
+ isAbsolute(privacyKeyRelativePath)
907
+ ) {
908
+ throw new Error(
909
+ "config.preparation.privacyKey must resolve to a secret file inside config.baseline.artifactDirectory.",
910
+ );
911
+ }
912
+ }
623
913
  if (!IDENTIFIER_PATTERN.test(config.runtime.projectId)) {
624
914
  throw new Error(
625
915
  "config.runtime.projectId must contain only lowercase letters, digits, and hyphens.",
@@ -726,6 +1016,21 @@ export default defineRehearsalConfig({
726
1016
  sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
727
1017
  },
728
1018
 
1019
+ // Optional source refresh. Enable only after reviewing docs/standalone-workflow.md.
1020
+ // Credentials are read from the environment named by source-access-policy.json.
1021
+ // preparation: {
1022
+ // sourcePolicy: "infrastructure/rehearsal/source-access-policy.json",
1023
+ // privacyKey: ".rehearsal/secrets/privacy.key",
1024
+ // batchRows: 500,
1025
+ // maximumRows: 1000000,
1026
+ // maximumBytes: 2147483648,
1027
+ // diskHeadroomBytes: 67108864,
1028
+ // },
1029
+ // Optional reviewed schemas/extensions/triggers/local-only rows.
1030
+ // runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
1031
+ // Optional signup defaults, role/reference transfer, Storage paths, and token checks.
1032
+ // identityPolicy: "infrastructure/rehearsal/identity-policy.json",
1033
+
729
1034
  // Reuse any running Docker-compatible engine; start Colima only when needed.
730
1035
  containerRuntime: {
731
1036
  autoStartColima: true,
@@ -736,11 +1041,26 @@ export default defineRehearsalConfig({
736
1041
  retainBaselineGenerations: 2,
737
1042
  },
738
1043
 
1044
+ // Optional: manage another isolated database as part of the same rehearsal.
1045
+ // Give that database its own complete config, ports, project ID, and .rehearsal folder.
1046
+ // dependentTargets: [
1047
+ // {
1048
+ // name: "publication-api",
1049
+ // configPath: "rehearsal.publication.config.mjs",
1050
+ // prepareCommand: "npm run rehearsal:prepare-publication",
1051
+ // },
1052
+ // ],
1053
+
739
1054
  application: {
740
1055
  // CHECK: commands detected from package.json. Change them if they are not correct.
741
1056
  startCommand: ${JSON.stringify(detected.applicationCommand)},
742
1057
  proofCommand: ${JSON.stringify(detected.verificationCommand)},
743
1058
  environmentFile: ".rehearsal/runtime.env",
1059
+ // Map generated local runtime values directly into the normal app command.
1060
+ // environmentVariables: { DATABASE_URL: "primary:DATABASE_URL" },
1061
+ readiness: { url: ${JSON.stringify(applicationUrl)}, expectedStatus: 200, timeoutSeconds: 30 },
1062
+ // Add at least one real positive and one negative before enabling HTTP proofs.
1063
+ // httpProofs: [],
744
1064
  // Optional project-specific restore hook:
745
1065
  // runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
746
1066
  },
@@ -803,6 +1123,21 @@ export default defineRehearsalConfig({
803
1123
  sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
804
1124
  },
805
1125
 
1126
+ // Optional source refresh. Enable only after reviewing docs/standalone-workflow.md.
1127
+ // Credentials are read from the environment named by source-access-policy.json.
1128
+ // preparation: {
1129
+ // sourcePolicy: "infrastructure/rehearsal/source-access-policy.json",
1130
+ // privacyKey: ".rehearsal/secrets/privacy.key",
1131
+ // batchRows: 500,
1132
+ // maximumRows: 1000000,
1133
+ // maximumBytes: 2147483648,
1134
+ // diskHeadroomBytes: 67108864,
1135
+ // },
1136
+ // Optional reviewed schemas/extensions/triggers/local-only rows.
1137
+ // runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
1138
+ // Optional signup defaults, role/reference transfer, Storage paths, and token checks.
1139
+ // identityPolicy: "infrastructure/rehearsal/identity-policy.json",
1140
+
806
1141
  // Reuse any running Docker-compatible engine; start Colima only when needed.
807
1142
  containerRuntime: {
808
1143
  autoStartColima: true,
@@ -813,11 +1148,26 @@ export default defineRehearsalConfig({
813
1148
  retainBaselineGenerations: 2,
814
1149
  },
815
1150
 
1151
+ // Optional: manage another isolated database as part of the same rehearsal.
1152
+ // Give that database its own complete config, ports, project ID, and .rehearsal folder.
1153
+ // dependentTargets: [
1154
+ // {
1155
+ // name: "publication-api",
1156
+ // configPath: "rehearsal.publication.config.mjs",
1157
+ // prepareCommand: "npm run rehearsal:prepare-publication",
1158
+ // },
1159
+ // ],
1160
+
816
1161
  application: {
817
1162
  // CHECK: commands detected from package.json. Change them if they are not correct.
818
1163
  startCommand: ${JSON.stringify(detected.applicationCommand)},
819
1164
  proofCommand: ${JSON.stringify(detected.verificationCommand)},
820
1165
  environmentFile: ".rehearsal/runtime.env",
1166
+ // Map generated local runtime values directly into the normal app command.
1167
+ // environmentVariables: { DATABASE_URL: "primary:DATABASE_URL" },
1168
+ readiness: { url: ${JSON.stringify(applicationUrl)}, expectedStatus: 200, timeoutSeconds: 30 },
1169
+ // Add at least one real positive and one negative before enabling HTTP proofs.
1170
+ // httpProofs: [],
821
1171
  // Optional project-specific restore hook:
822
1172
  // runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
823
1173
  },
@@ -11,7 +11,7 @@ import {
11
11
  renderDetectedConfig,
12
12
  renderDetectedPostgresqlConfig,
13
13
  } from "./configuration.mjs";
14
- import { resolveRuntimeTarget } from "../runtime/runtime_target.mjs";
14
+ import { resolveRuntimeTarget } from "../targets/target.mjs";
15
15
 
16
16
  const CONFIG_PATH = "rehearsal.config.mjs";
17
17
  const LOCAL_SUPABASE_CONFIG_PATH =
@@ -6,8 +6,11 @@
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { arch, platform, release } from "node:os";
9
- import { REHEARSAL_VERSION, redactDiagnosticValue } from "./diagnostics.mjs";
10
- import { runRehearsalDoctor } from "./plan.mjs";
9
+ import {
10
+ REHEARSAL_VERSION,
11
+ redactDiagnosticValue,
12
+ } from "../shared/diagnostics.mjs";
13
+ import { runRehearsalDoctor } from "../runtime/plan.mjs";
11
14
 
12
15
  export const REHEARSAL_SUPPORT_URL =
13
16
  "https://github.com/Ddupasquier/rehearsal-db/issues/new?template=bug.yml";