@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,958 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Load and strictly validate the versioned, project-owned Rehearsal
|
|
3
|
+
* configuration contract. Do not run directly; this module is reusable script
|
|
4
|
+
* infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
7
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { resolveRuntimeTarget } from "../targets/target.mjs";
|
|
10
|
+
export const REHEARSAL_CONFIG_VERSION = 1;
|
|
11
|
+
export const REHEARSAL_DEFAULTS = Object.freeze({
|
|
12
|
+
baseline: Object.freeze({ artifactDirectory: ".rehearsal" }),
|
|
13
|
+
containerRuntime: Object.freeze({ autoStartColima: true }),
|
|
14
|
+
cleanup: Object.freeze({ retainBaselineGenerations: 2 }),
|
|
15
|
+
runtime: Object.freeze({
|
|
16
|
+
applicationUrl: "http://localhost:5175",
|
|
17
|
+
projectId: "rehearsal-local",
|
|
18
|
+
}),
|
|
19
|
+
safety: Object.freeze({
|
|
20
|
+
allowedHosts: Object.freeze(["127.0.0.1", "::1", "localhost"]),
|
|
21
|
+
blockedEnvironmentVariables: Object.freeze([
|
|
22
|
+
"DATABASE_URL",
|
|
23
|
+
"PGHOST",
|
|
24
|
+
"PGPASSWORD",
|
|
25
|
+
"PGPORT",
|
|
26
|
+
"PGUSER",
|
|
27
|
+
"SUPABASE_ACCESS_TOKEN",
|
|
28
|
+
"SUPABASE_DB_PASSWORD",
|
|
29
|
+
"SUPABASE_PROJECT_ID",
|
|
30
|
+
]),
|
|
31
|
+
hostedAccess: "disabled",
|
|
32
|
+
outboundNetwork: "deny",
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
const CONFIG_FILENAMES = Object.freeze([
|
|
36
|
+
"rehearsal.config.mjs",
|
|
37
|
+
"rehearsal.config.js",
|
|
38
|
+
"rehearsal.config.ts",
|
|
39
|
+
join("infrastructure", "rehearsal", "rehearsal.config.mjs"),
|
|
40
|
+
join("infrastructure", "rehearsal", "rehearsal.config.ts"),
|
|
41
|
+
]);
|
|
42
|
+
const LOOPBACK_HOSTS = new Set(REHEARSAL_DEFAULTS.safety.allowedHosts);
|
|
43
|
+
const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}$/u;
|
|
44
|
+
const DATABASE_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
45
|
+
const POSTGRES_IMAGE_PATTERN = /^postgres:\d+(?:\.\d+)?-alpine$/u;
|
|
46
|
+
const SAFE_COMMAND_PATTERN = /^[^\n\r\0]+$/u;
|
|
47
|
+
const ENVIRONMENT_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/u;
|
|
48
|
+
const ENVIRONMENT_REFERENCE_PATTERN = /^[a-z0-9][a-z0-9-]*:[A-Z][A-Z0-9_]*$/u;
|
|
49
|
+
const SUPPORTED_SUPABASE_OAUTH_PROVIDERS = new Set(["github", "google"]);
|
|
50
|
+
const hasErrorCode = (error, code) => typeof error === "object" &&
|
|
51
|
+
error !== null &&
|
|
52
|
+
"code" in error &&
|
|
53
|
+
error.code === code;
|
|
54
|
+
const isPlainObject = (value) => value !== null &&
|
|
55
|
+
typeof value === "object" &&
|
|
56
|
+
!Array.isArray(value) &&
|
|
57
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
58
|
+
const assertPlainObject = (value, path) => {
|
|
59
|
+
if (!isPlainObject(value)) {
|
|
60
|
+
throw new Error(`${path} must be an object.`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
const assertKnownKeys = (value, keys, path) => {
|
|
65
|
+
for (const key of Object.keys(value)) {
|
|
66
|
+
if (!keys.includes(key)) {
|
|
67
|
+
throw new Error(`Unknown Rehearsal configuration property: ${path}.${key}.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const assertNonEmptyString = (value, path) => {
|
|
72
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
73
|
+
throw new Error(`${path} must be a non-empty string.`);
|
|
74
|
+
}
|
|
75
|
+
return value.trim();
|
|
76
|
+
};
|
|
77
|
+
const assertRelativePath = (value, path) => {
|
|
78
|
+
const normalized = assertNonEmptyString(value, path);
|
|
79
|
+
if (isAbsolute(normalized) || normalized.split(/[\\/]/u).includes("..")) {
|
|
80
|
+
throw new Error(`${path} must stay inside the project root.`);
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
83
|
+
};
|
|
84
|
+
const assertCommand = (value, path) => {
|
|
85
|
+
const command = assertNonEmptyString(value, path);
|
|
86
|
+
if (!SAFE_COMMAND_PATTERN.test(command)) {
|
|
87
|
+
throw new Error(`${path} contains an unsafe control character.`);
|
|
88
|
+
}
|
|
89
|
+
return command;
|
|
90
|
+
};
|
|
91
|
+
const assertPort = (value, path) => {
|
|
92
|
+
if (typeof value !== "number" ||
|
|
93
|
+
!Number.isSafeInteger(value) ||
|
|
94
|
+
value < 1024 ||
|
|
95
|
+
value > 65_535) {
|
|
96
|
+
throw new Error(`${path} must be a non-privileged TCP port.`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
};
|
|
100
|
+
const assertBoolean = (value, path) => {
|
|
101
|
+
if (typeof value !== "boolean") {
|
|
102
|
+
throw new Error(`${path} must be true or false.`);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
const assertPositiveInteger = (value, path) => {
|
|
107
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
108
|
+
throw new Error(`${path} must be a positive integer.`);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
};
|
|
112
|
+
const assertHttpStatus = (value, path) => {
|
|
113
|
+
if (typeof value !== "number" ||
|
|
114
|
+
!Number.isSafeInteger(value) ||
|
|
115
|
+
value < 100 ||
|
|
116
|
+
value > 599) {
|
|
117
|
+
throw new Error(`${path} must be an HTTP status.`);
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
};
|
|
121
|
+
const assertStringArray = (value, path) => {
|
|
122
|
+
if (!Array.isArray(value) ||
|
|
123
|
+
value.length === 0 ||
|
|
124
|
+
value.some((entry) => typeof entry !== "string" || !entry.trim())) {
|
|
125
|
+
throw new Error(`${path} must be a non-empty string array.`);
|
|
126
|
+
}
|
|
127
|
+
return [...new Set(value.map((entry) => entry.trim()))];
|
|
128
|
+
};
|
|
129
|
+
const normalizeApplicationEnvironmentMappings = (value = {}) => {
|
|
130
|
+
const mappings = assertPlainObject(value, "config.application.environmentVariables");
|
|
131
|
+
return Object.freeze(Object.fromEntries(Object.entries(mappings).map(([key, reference]) => {
|
|
132
|
+
if (!ENVIRONMENT_KEY_PATTERN.test(key)) {
|
|
133
|
+
throw new Error(`config.application.environmentVariables.${key} is not a safe environment variable name.`);
|
|
134
|
+
}
|
|
135
|
+
if (typeof reference !== "string" ||
|
|
136
|
+
!ENVIRONMENT_REFERENCE_PATTERN.test(reference)) {
|
|
137
|
+
throw new Error(`config.application.environmentVariables.${key} must reference target:VARIABLE, such as primary:DATABASE_URL.`);
|
|
138
|
+
}
|
|
139
|
+
return [key, reference];
|
|
140
|
+
})));
|
|
141
|
+
};
|
|
142
|
+
const assertLoopbackUrl = (value, path, allowedHosts) => {
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = new URL(assertNonEmptyString(value, path));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new Error(`${path} must be a valid loopback URL.`);
|
|
149
|
+
}
|
|
150
|
+
if (!allowedHosts.includes(parsed.hostname) ||
|
|
151
|
+
!LOOPBACK_HOSTS.has(parsed.hostname)) {
|
|
152
|
+
throw new Error(`${path} must use an explicitly allowed loopback host.`);
|
|
153
|
+
}
|
|
154
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
155
|
+
throw new Error(`${path} must use HTTP or HTTPS.`);
|
|
156
|
+
}
|
|
157
|
+
return parsed.toString().replace(/\/$/u, "");
|
|
158
|
+
};
|
|
159
|
+
const normalizeConfig = (input) => {
|
|
160
|
+
const root = assertPlainObject(input, "config");
|
|
161
|
+
assertKnownKeys(root, [
|
|
162
|
+
"schemaVersion",
|
|
163
|
+
"project",
|
|
164
|
+
"supabase",
|
|
165
|
+
"postgresql",
|
|
166
|
+
"baseline",
|
|
167
|
+
"containerRuntime",
|
|
168
|
+
"cleanup",
|
|
169
|
+
"preparation",
|
|
170
|
+
"runtimePolicy",
|
|
171
|
+
"identityPolicy",
|
|
172
|
+
"dependentTargets",
|
|
173
|
+
"application",
|
|
174
|
+
"runtime",
|
|
175
|
+
"safety",
|
|
176
|
+
"verification",
|
|
177
|
+
], "config");
|
|
178
|
+
if (root.schemaVersion !== REHEARSAL_CONFIG_VERSION) {
|
|
179
|
+
throw new Error(`Unsupported Rehearsal configuration version: ${String(root.schemaVersion)}. Expected ${REHEARSAL_CONFIG_VERSION}.`);
|
|
180
|
+
}
|
|
181
|
+
const project = assertPlainObject(root.project, "config.project");
|
|
182
|
+
assertKnownKeys(project, ["name"], "config.project");
|
|
183
|
+
const projectName = assertNonEmptyString(project.name, "config.project.name");
|
|
184
|
+
if (!IDENTIFIER_PATTERN.test(projectName)) {
|
|
185
|
+
throw new Error("config.project.name must contain only lowercase letters, digits, and hyphens.");
|
|
186
|
+
}
|
|
187
|
+
const runtime = assertPlainObject(root.runtime ?? {}, "config.runtime");
|
|
188
|
+
const runtimeTarget = resolveRuntimeTarget(runtime.target).id;
|
|
189
|
+
const supabase = runtimeTarget === "supabase"
|
|
190
|
+
? assertPlainObject(root.supabase, "config.supabase")
|
|
191
|
+
: null;
|
|
192
|
+
const postgresql = runtimeTarget === "postgresql"
|
|
193
|
+
? assertPlainObject(root.postgresql, "config.postgresql")
|
|
194
|
+
: null;
|
|
195
|
+
if (runtimeTarget === "supabase" && root.postgresql !== undefined) {
|
|
196
|
+
throw new Error("config.postgresql is not allowed when config.runtime.target is supabase.");
|
|
197
|
+
}
|
|
198
|
+
if (runtimeTarget === "postgresql" && root.supabase !== undefined) {
|
|
199
|
+
throw new Error("config.supabase is not allowed when config.runtime.target is postgresql.");
|
|
200
|
+
}
|
|
201
|
+
if (supabase) {
|
|
202
|
+
assertKnownKeys(supabase, [
|
|
203
|
+
"workdir",
|
|
204
|
+
"migrationDirectory",
|
|
205
|
+
"rehearsalConfig",
|
|
206
|
+
"runtimeWorkdir",
|
|
207
|
+
"authentication",
|
|
208
|
+
"serviceEnvironmentFile",
|
|
209
|
+
"serviceEnvironmentVariables",
|
|
210
|
+
], "config.supabase");
|
|
211
|
+
}
|
|
212
|
+
if (postgresql) {
|
|
213
|
+
assertKnownKeys(postgresql, ["migrationDirectory", "runtimeWorkdir", "image", "database", "user"], "config.postgresql");
|
|
214
|
+
}
|
|
215
|
+
const baseline = assertPlainObject(root.baseline, "config.baseline");
|
|
216
|
+
assertKnownKeys(baseline, ["artifactDirectory", "sanitizationPolicy"], "config.baseline");
|
|
217
|
+
const containerRuntime = assertPlainObject(root.containerRuntime ?? {}, "config.containerRuntime");
|
|
218
|
+
assertKnownKeys(containerRuntime, ["autoStartColima"], "config.containerRuntime");
|
|
219
|
+
const cleanup = assertPlainObject(root.cleanup ?? {}, "config.cleanup");
|
|
220
|
+
assertKnownKeys(cleanup, ["retainBaselineGenerations"], "config.cleanup");
|
|
221
|
+
const preparation = root.preparation === undefined
|
|
222
|
+
? null
|
|
223
|
+
: assertPlainObject(root.preparation, "config.preparation");
|
|
224
|
+
if (preparation) {
|
|
225
|
+
assertKnownKeys(preparation, [
|
|
226
|
+
"sourcePolicy",
|
|
227
|
+
"privacyKey",
|
|
228
|
+
"batchRows",
|
|
229
|
+
"maximumRows",
|
|
230
|
+
"maximumBytes",
|
|
231
|
+
"diskHeadroomBytes",
|
|
232
|
+
], "config.preparation");
|
|
233
|
+
}
|
|
234
|
+
const dependentTargets = root.dependentTargets ?? [];
|
|
235
|
+
if (!Array.isArray(dependentTargets)) {
|
|
236
|
+
throw new Error("config.dependentTargets must be an array.");
|
|
237
|
+
}
|
|
238
|
+
const normalizedDependentTargets = dependentTargets.map((entry, index) => {
|
|
239
|
+
const path = `config.dependentTargets[${index}]`;
|
|
240
|
+
const target = assertPlainObject(entry, path);
|
|
241
|
+
assertKnownKeys(target, ["name", "configPath", "prepareCommand"], path);
|
|
242
|
+
const name = assertNonEmptyString(target.name, `${path}.name`);
|
|
243
|
+
if (!IDENTIFIER_PATTERN.test(name)) {
|
|
244
|
+
throw new Error(`${path}.name must contain only lowercase letters, digits, and hyphens.`);
|
|
245
|
+
}
|
|
246
|
+
return Object.freeze({
|
|
247
|
+
name,
|
|
248
|
+
configPath: assertRelativePath(target.configPath, `${path}.configPath`),
|
|
249
|
+
prepareCommand: target.prepareCommand === undefined
|
|
250
|
+
? null
|
|
251
|
+
: assertCommand(target.prepareCommand, `${path}.prepareCommand`),
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
if (new Set(normalizedDependentTargets.map((target) => target.name)).size !==
|
|
255
|
+
normalizedDependentTargets.length) {
|
|
256
|
+
throw new Error("config.dependentTargets names must be unique.");
|
|
257
|
+
}
|
|
258
|
+
const application = assertPlainObject(root.application, "config.application");
|
|
259
|
+
assertKnownKeys(application, [
|
|
260
|
+
"startCommand",
|
|
261
|
+
"proofCommand",
|
|
262
|
+
"environmentFile",
|
|
263
|
+
"environmentVariables",
|
|
264
|
+
"readiness",
|
|
265
|
+
"httpProofs",
|
|
266
|
+
"runtimeAdapter",
|
|
267
|
+
], "config.application");
|
|
268
|
+
const readiness = application.readiness === undefined
|
|
269
|
+
? null
|
|
270
|
+
: assertPlainObject(application.readiness, "config.application.readiness");
|
|
271
|
+
if (readiness) {
|
|
272
|
+
assertKnownKeys(readiness, ["url", "expectedStatus", "timeoutSeconds"], "config.application.readiness");
|
|
273
|
+
}
|
|
274
|
+
const httpProofs = application.httpProofs ?? [];
|
|
275
|
+
if (!Array.isArray(httpProofs)) {
|
|
276
|
+
throw new Error("config.application.httpProofs must be an array.");
|
|
277
|
+
}
|
|
278
|
+
if (httpProofs.length > 0 &&
|
|
279
|
+
(!httpProofs.some((proof) => proof?.kind === "positive") ||
|
|
280
|
+
!httpProofs.some((proof) => proof?.kind === "negative"))) {
|
|
281
|
+
throw new Error("config.application.httpProofs must include at least one positive and one negative check.");
|
|
282
|
+
}
|
|
283
|
+
assertKnownKeys(runtime, [
|
|
284
|
+
"target",
|
|
285
|
+
"applicationUrl",
|
|
286
|
+
"projectId",
|
|
287
|
+
"apiPort",
|
|
288
|
+
"databasePort",
|
|
289
|
+
"studioPort",
|
|
290
|
+
], "config.runtime");
|
|
291
|
+
const safety = assertPlainObject(root.safety ?? {}, "config.safety");
|
|
292
|
+
assertKnownKeys(safety, [
|
|
293
|
+
"allowedHosts",
|
|
294
|
+
"blockedEnvironmentVariables",
|
|
295
|
+
"authenticationProviders",
|
|
296
|
+
"hostedAccess",
|
|
297
|
+
"outboundNetwork",
|
|
298
|
+
], "config.safety");
|
|
299
|
+
const verification = assertPlainObject(root.verification ?? {}, "config.verification");
|
|
300
|
+
assertKnownKeys(verification, ["commands"], "config.verification");
|
|
301
|
+
const allowedHosts = assertStringArray(safety.allowedHosts ?? REHEARSAL_DEFAULTS.safety.allowedHosts, "config.safety.allowedHosts");
|
|
302
|
+
if (allowedHosts.some((host) => !LOOPBACK_HOSTS.has(host))) {
|
|
303
|
+
throw new Error("config.safety.allowedHosts may contain loopback hosts only in configuration version 1.");
|
|
304
|
+
}
|
|
305
|
+
if ((safety.hostedAccess ?? "disabled") !== "disabled") {
|
|
306
|
+
throw new Error("config.safety.hostedAccess must remain disabled in configuration version 1.");
|
|
307
|
+
}
|
|
308
|
+
if ((safety.outboundNetwork ?? "deny") !== "deny") {
|
|
309
|
+
throw new Error("config.safety.outboundNetwork must remain deny in configuration version 1.");
|
|
310
|
+
}
|
|
311
|
+
const ports = {
|
|
312
|
+
...(runtimeTarget === "supabase"
|
|
313
|
+
? { api: assertPort(runtime.apiPort, "config.runtime.apiPort") }
|
|
314
|
+
: {}),
|
|
315
|
+
database: assertPort(runtime.databasePort, "config.runtime.databasePort"),
|
|
316
|
+
...(runtimeTarget === "supabase"
|
|
317
|
+
? { studio: assertPort(runtime.studioPort, "config.runtime.studioPort") }
|
|
318
|
+
: {}),
|
|
319
|
+
};
|
|
320
|
+
if (new Set(Object.values(ports)).size !== Object.values(ports).length) {
|
|
321
|
+
throw new Error("Rehearsal runtime ports must be unique.");
|
|
322
|
+
}
|
|
323
|
+
const commands = verification.commands ?? [];
|
|
324
|
+
if (!Array.isArray(commands)) {
|
|
325
|
+
throw new Error("config.verification.commands must be an array.");
|
|
326
|
+
}
|
|
327
|
+
const authentication = supabase?.authentication === undefined
|
|
328
|
+
? null
|
|
329
|
+
: assertPlainObject(supabase.authentication, "config.supabase.authentication");
|
|
330
|
+
let normalizedAuthentication = null;
|
|
331
|
+
if (authentication) {
|
|
332
|
+
if (!supabase) {
|
|
333
|
+
throw new Error("config.supabase.authentication requires the Supabase runtime target.");
|
|
334
|
+
}
|
|
335
|
+
assertKnownKeys(authentication, ["enableLocalSignup", "environmentFile", "providers"], "config.supabase.authentication");
|
|
336
|
+
if (authentication.enableLocalSignup !== true) {
|
|
337
|
+
throw new Error("config.supabase.authentication.enableLocalSignup must be true to create the disposable local identity used by an OAuth sign-in.");
|
|
338
|
+
}
|
|
339
|
+
if (supabase.serviceEnvironmentFile !== undefined ||
|
|
340
|
+
supabase.serviceEnvironmentVariables !== undefined) {
|
|
341
|
+
throw new Error("config.supabase.authentication cannot be combined with the legacy serviceEnvironmentFile or serviceEnvironmentVariables fields.");
|
|
342
|
+
}
|
|
343
|
+
if (safety.authenticationProviders !== undefined) {
|
|
344
|
+
throw new Error("config.safety.authenticationProviders is derived from config.supabase.authentication and must be omitted.");
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(authentication.providers) ||
|
|
347
|
+
authentication.providers.length === 0) {
|
|
348
|
+
throw new Error("config.supabase.authentication.providers must be a non-empty array.");
|
|
349
|
+
}
|
|
350
|
+
const providers = authentication.providers.map((entry, index) => {
|
|
351
|
+
const path = `config.supabase.authentication.providers[${index}]`;
|
|
352
|
+
const value = assertPlainObject(entry, path);
|
|
353
|
+
assertKnownKeys(value, [
|
|
354
|
+
"name",
|
|
355
|
+
"clientIdEnvironmentVariable",
|
|
356
|
+
"clientSecretEnvironmentVariable",
|
|
357
|
+
"skipNonceCheck",
|
|
358
|
+
"emailOptional",
|
|
359
|
+
], path);
|
|
360
|
+
const reviewedName = assertNonEmptyString(value.name, `${path}.name`);
|
|
361
|
+
if (!SUPPORTED_SUPABASE_OAUTH_PROVIDERS.has(reviewedName)) {
|
|
362
|
+
throw new Error(`${path}.name must be one of: ${[...SUPPORTED_SUPABASE_OAUTH_PROVIDERS].join(", ")}.`);
|
|
363
|
+
}
|
|
364
|
+
const name = reviewedName;
|
|
365
|
+
const environmentVariable = (key) => {
|
|
366
|
+
const variable = assertNonEmptyString(value[key], `${path}.${key}`);
|
|
367
|
+
if (!ENVIRONMENT_KEY_PATTERN.test(variable)) {
|
|
368
|
+
throw new Error(`${path}.${key} must be a safe environment variable name.`);
|
|
369
|
+
}
|
|
370
|
+
return variable;
|
|
371
|
+
};
|
|
372
|
+
const optionalBoolean = (key) => {
|
|
373
|
+
if (value[key] === undefined)
|
|
374
|
+
return false;
|
|
375
|
+
if (typeof value[key] !== "boolean") {
|
|
376
|
+
throw new Error(`${path}.${key} must be a boolean.`);
|
|
377
|
+
}
|
|
378
|
+
return value[key];
|
|
379
|
+
};
|
|
380
|
+
return Object.freeze({
|
|
381
|
+
name,
|
|
382
|
+
clientIdEnvironmentVariable: environmentVariable("clientIdEnvironmentVariable"),
|
|
383
|
+
clientSecretEnvironmentVariable: environmentVariable("clientSecretEnvironmentVariable"),
|
|
384
|
+
skipNonceCheck: optionalBoolean("skipNonceCheck"),
|
|
385
|
+
emailOptional: optionalBoolean("emailOptional"),
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
if (new Set(providers.map(({ name }) => name)).size !==
|
|
389
|
+
providers.length) {
|
|
390
|
+
throw new Error("config.supabase.authentication.providers must not contain duplicate providers.");
|
|
391
|
+
}
|
|
392
|
+
const credentialVariables = providers.flatMap((provider) => [
|
|
393
|
+
provider.clientIdEnvironmentVariable,
|
|
394
|
+
provider.clientSecretEnvironmentVariable,
|
|
395
|
+
]);
|
|
396
|
+
if (new Set(credentialVariables).size !== credentialVariables.length) {
|
|
397
|
+
throw new Error("config.supabase.authentication provider environment variables must be unique.");
|
|
398
|
+
}
|
|
399
|
+
normalizedAuthentication = Object.freeze({
|
|
400
|
+
enableLocalSignup: true,
|
|
401
|
+
environmentFile: assertRelativePath(authentication.environmentFile, "config.supabase.authentication.environmentFile"),
|
|
402
|
+
providers: Object.freeze(providers),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
const serviceEnvironmentVariables = normalizedAuthentication
|
|
406
|
+
? normalizedAuthentication.providers.flatMap((provider) => [
|
|
407
|
+
provider.clientIdEnvironmentVariable,
|
|
408
|
+
provider.clientSecretEnvironmentVariable,
|
|
409
|
+
])
|
|
410
|
+
: (supabase?.serviceEnvironmentVariables ?? []);
|
|
411
|
+
if (!Array.isArray(serviceEnvironmentVariables)) {
|
|
412
|
+
throw new Error("config.supabase.serviceEnvironmentVariables must be an array.");
|
|
413
|
+
}
|
|
414
|
+
const normalizedServiceEnvironmentVariables = serviceEnvironmentVariables.map((value, index) => assertNonEmptyString(value, `config.supabase.serviceEnvironmentVariables[${index}]`));
|
|
415
|
+
if (new Set(normalizedServiceEnvironmentVariables).size !==
|
|
416
|
+
normalizedServiceEnvironmentVariables.length) {
|
|
417
|
+
throw new Error("config.supabase.serviceEnvironmentVariables must not contain duplicates.");
|
|
418
|
+
}
|
|
419
|
+
if (Boolean(normalizedAuthentication?.environmentFile ??
|
|
420
|
+
supabase?.serviceEnvironmentFile) !== Boolean(normalizedServiceEnvironmentVariables.length)) {
|
|
421
|
+
throw new Error("config.supabase.serviceEnvironmentFile and serviceEnvironmentVariables must be configured together.");
|
|
422
|
+
}
|
|
423
|
+
const authenticationProviders = normalizedAuthentication
|
|
424
|
+
? normalizedAuthentication.providers.map(({ name }) => name)
|
|
425
|
+
: safety.authenticationProviders
|
|
426
|
+
? assertStringArray(safety.authenticationProviders, "config.safety.authenticationProviders")
|
|
427
|
+
: [];
|
|
428
|
+
if (runtimeTarget === "postgresql" && authenticationProviders.length > 0) {
|
|
429
|
+
throw new Error("config.safety.authenticationProviders is available only for the Supabase target.");
|
|
430
|
+
}
|
|
431
|
+
return Object.freeze({
|
|
432
|
+
schemaVersion: REHEARSAL_CONFIG_VERSION,
|
|
433
|
+
project: Object.freeze({ name: projectName }),
|
|
434
|
+
supabase: supabase
|
|
435
|
+
? Object.freeze({
|
|
436
|
+
workdir: assertRelativePath(supabase.workdir, "config.supabase.workdir"),
|
|
437
|
+
migrationDirectory: assertRelativePath(supabase.migrationDirectory, "config.supabase.migrationDirectory"),
|
|
438
|
+
rehearsalConfig: assertRelativePath(supabase.rehearsalConfig, "config.supabase.rehearsalConfig"),
|
|
439
|
+
runtimeWorkdir: assertRelativePath(supabase.runtimeWorkdir, "config.supabase.runtimeWorkdir"),
|
|
440
|
+
authentication: normalizedAuthentication,
|
|
441
|
+
serviceEnvironmentFile: normalizedAuthentication
|
|
442
|
+
? normalizedAuthentication.environmentFile
|
|
443
|
+
: supabase.serviceEnvironmentFile
|
|
444
|
+
? assertRelativePath(supabase.serviceEnvironmentFile, "config.supabase.serviceEnvironmentFile")
|
|
445
|
+
: null,
|
|
446
|
+
serviceEnvironmentVariables: Object.freeze(normalizedServiceEnvironmentVariables),
|
|
447
|
+
})
|
|
448
|
+
: null,
|
|
449
|
+
postgresql: postgresql
|
|
450
|
+
? Object.freeze({
|
|
451
|
+
migrationDirectory: assertRelativePath(postgresql.migrationDirectory, "config.postgresql.migrationDirectory"),
|
|
452
|
+
runtimeWorkdir: assertRelativePath(postgresql.runtimeWorkdir ?? ".rehearsal/runtime", "config.postgresql.runtimeWorkdir"),
|
|
453
|
+
image: (() => {
|
|
454
|
+
const image = assertNonEmptyString(postgresql.image ?? "postgres:17-alpine", "config.postgresql.image");
|
|
455
|
+
if (!POSTGRES_IMAGE_PATTERN.test(image)) {
|
|
456
|
+
throw new Error("config.postgresql.image must name an official versioned Alpine PostgreSQL image, such as postgres:17-alpine.");
|
|
457
|
+
}
|
|
458
|
+
return image;
|
|
459
|
+
})(),
|
|
460
|
+
database: (() => {
|
|
461
|
+
const database = assertNonEmptyString(postgresql.database ?? "postgres", "config.postgresql.database");
|
|
462
|
+
if (!DATABASE_IDENTIFIER_PATTERN.test(database)) {
|
|
463
|
+
throw new Error("config.postgresql.database must be a safe lowercase PostgreSQL identifier.");
|
|
464
|
+
}
|
|
465
|
+
return database;
|
|
466
|
+
})(),
|
|
467
|
+
user: (() => {
|
|
468
|
+
const user = assertNonEmptyString(postgresql.user ?? "postgres", "config.postgresql.user");
|
|
469
|
+
if (!DATABASE_IDENTIFIER_PATTERN.test(user)) {
|
|
470
|
+
throw new Error("config.postgresql.user must be a safe lowercase PostgreSQL identifier.");
|
|
471
|
+
}
|
|
472
|
+
return user;
|
|
473
|
+
})(),
|
|
474
|
+
})
|
|
475
|
+
: null,
|
|
476
|
+
baseline: Object.freeze({
|
|
477
|
+
artifactDirectory: assertRelativePath(baseline.artifactDirectory ??
|
|
478
|
+
REHEARSAL_DEFAULTS.baseline.artifactDirectory, "config.baseline.artifactDirectory"),
|
|
479
|
+
sanitizationPolicy: assertRelativePath(baseline.sanitizationPolicy, "config.baseline.sanitizationPolicy"),
|
|
480
|
+
}),
|
|
481
|
+
containerRuntime: Object.freeze({
|
|
482
|
+
autoStartColima: assertBoolean(containerRuntime.autoStartColima ??
|
|
483
|
+
REHEARSAL_DEFAULTS.containerRuntime.autoStartColima, "config.containerRuntime.autoStartColima"),
|
|
484
|
+
}),
|
|
485
|
+
cleanup: Object.freeze({
|
|
486
|
+
retainBaselineGenerations: assertPositiveInteger(cleanup.retainBaselineGenerations ??
|
|
487
|
+
REHEARSAL_DEFAULTS.cleanup.retainBaselineGenerations, "config.cleanup.retainBaselineGenerations"),
|
|
488
|
+
}),
|
|
489
|
+
preparation: preparation
|
|
490
|
+
? Object.freeze({
|
|
491
|
+
sourcePolicy: assertRelativePath(preparation.sourcePolicy, "config.preparation.sourcePolicy"),
|
|
492
|
+
privacyKey: assertRelativePath(preparation.privacyKey, "config.preparation.privacyKey"),
|
|
493
|
+
batchRows: assertPositiveInteger(preparation.batchRows ?? 500, "config.preparation.batchRows"),
|
|
494
|
+
maximumRows: assertPositiveInteger(preparation.maximumRows ?? 1_000_000, "config.preparation.maximumRows"),
|
|
495
|
+
maximumBytes: assertPositiveInteger(preparation.maximumBytes ?? 2 * 1024 * 1024 * 1024, "config.preparation.maximumBytes"),
|
|
496
|
+
diskHeadroomBytes: assertPositiveInteger(preparation.diskHeadroomBytes ?? 64 * 1024 * 1024, "config.preparation.diskHeadroomBytes"),
|
|
497
|
+
})
|
|
498
|
+
: null,
|
|
499
|
+
runtimePolicy: root.runtimePolicy === undefined
|
|
500
|
+
? null
|
|
501
|
+
: assertRelativePath(root.runtimePolicy, "config.runtimePolicy"),
|
|
502
|
+
identityPolicy: root.identityPolicy === undefined
|
|
503
|
+
? null
|
|
504
|
+
: assertRelativePath(root.identityPolicy, "config.identityPolicy"),
|
|
505
|
+
dependentTargets: Object.freeze(normalizedDependentTargets),
|
|
506
|
+
application: Object.freeze({
|
|
507
|
+
startCommand: assertCommand(application.startCommand, "config.application.startCommand"),
|
|
508
|
+
proofCommand: assertCommand(application.proofCommand, "config.application.proofCommand"),
|
|
509
|
+
environmentFile: assertRelativePath(application.environmentFile ?? ".rehearsal/runtime.env", "config.application.environmentFile"),
|
|
510
|
+
environmentVariables: normalizeApplicationEnvironmentMappings(application.environmentVariables),
|
|
511
|
+
readiness: readiness
|
|
512
|
+
? Object.freeze({
|
|
513
|
+
url: assertLoopbackUrl(readiness.url, "config.application.readiness.url", allowedHosts),
|
|
514
|
+
expectedStatus: assertHttpStatus(readiness.expectedStatus, "config.application.readiness.expectedStatus"),
|
|
515
|
+
timeoutSeconds: assertPositiveInteger(readiness.timeoutSeconds ?? 30, "config.application.readiness.timeoutSeconds"),
|
|
516
|
+
})
|
|
517
|
+
: null,
|
|
518
|
+
httpProofs: Object.freeze(httpProofs.map((proof, index) => {
|
|
519
|
+
const path = `config.application.httpProofs[${index}]`;
|
|
520
|
+
const value = assertPlainObject(proof, path);
|
|
521
|
+
assertKnownKeys(value, ["name", "kind", "url", "method", "expectedStatus", "json"], path);
|
|
522
|
+
if (typeof value.kind !== "string" ||
|
|
523
|
+
!["positive", "negative"].includes(value.kind)) {
|
|
524
|
+
throw new Error(`${path}.kind must be positive or negative.`);
|
|
525
|
+
}
|
|
526
|
+
const expectedStatus = assertHttpStatus(value.expectedStatus, `${path}.expectedStatus`);
|
|
527
|
+
const method = assertNonEmptyString(value.method ?? "GET", `${path}.method`).toUpperCase();
|
|
528
|
+
if (!["GET", "HEAD"].includes(method)) {
|
|
529
|
+
throw new Error(`${path}.method must be GET or HEAD.`);
|
|
530
|
+
}
|
|
531
|
+
let json = null;
|
|
532
|
+
if (value.json !== undefined) {
|
|
533
|
+
const assertion = assertPlainObject(value.json, `${path}.json`);
|
|
534
|
+
assertKnownKeys(assertion, ["path", "equals", "notEquals", "minimumItems"], `${path}.json`);
|
|
535
|
+
if (!Array.isArray(assertion.path) ||
|
|
536
|
+
assertion.path.length === 0 ||
|
|
537
|
+
assertion.path.some((segment) => typeof segment !== "string" || !segment)) {
|
|
538
|
+
throw new Error(`${path}.json.path must be a non-empty string array.`);
|
|
539
|
+
}
|
|
540
|
+
const assertionCount = [
|
|
541
|
+
Object.hasOwn(assertion, "equals"),
|
|
542
|
+
Object.hasOwn(assertion, "notEquals"),
|
|
543
|
+
assertion.minimumItems !== undefined,
|
|
544
|
+
].filter(Boolean).length;
|
|
545
|
+
if (assertionCount !== 1) {
|
|
546
|
+
throw new Error(`${path}.json must declare exactly one assertion.`);
|
|
547
|
+
}
|
|
548
|
+
json = Object.freeze({
|
|
549
|
+
path: Object.freeze([...assertion.path]),
|
|
550
|
+
...(Object.hasOwn(assertion, "equals")
|
|
551
|
+
? { equals: assertion.equals }
|
|
552
|
+
: {}),
|
|
553
|
+
...(Object.hasOwn(assertion, "notEquals")
|
|
554
|
+
? { notEquals: assertion.notEquals }
|
|
555
|
+
: {}),
|
|
556
|
+
...(assertion.minimumItems !== undefined
|
|
557
|
+
? {
|
|
558
|
+
minimumItems: assertPositiveInteger(assertion.minimumItems, `${path}.json.minimumItems`),
|
|
559
|
+
}
|
|
560
|
+
: {}),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return Object.freeze({
|
|
564
|
+
name: assertNonEmptyString(value.name, `${path}.name`),
|
|
565
|
+
kind: value.kind,
|
|
566
|
+
url: assertLoopbackUrl(value.url, `${path}.url`, allowedHosts),
|
|
567
|
+
method,
|
|
568
|
+
expectedStatus,
|
|
569
|
+
json,
|
|
570
|
+
});
|
|
571
|
+
})),
|
|
572
|
+
runtimeAdapter: application.runtimeAdapter === undefined
|
|
573
|
+
? null
|
|
574
|
+
: assertRelativePath(application.runtimeAdapter, "config.application.runtimeAdapter"),
|
|
575
|
+
}),
|
|
576
|
+
runtime: Object.freeze({
|
|
577
|
+
target: runtimeTarget,
|
|
578
|
+
applicationUrl: assertLoopbackUrl(runtime.applicationUrl ?? REHEARSAL_DEFAULTS.runtime.applicationUrl, "config.runtime.applicationUrl", allowedHosts),
|
|
579
|
+
projectId: assertNonEmptyString(runtime.projectId ?? REHEARSAL_DEFAULTS.runtime.projectId, "config.runtime.projectId"),
|
|
580
|
+
ports: Object.freeze(ports),
|
|
581
|
+
}),
|
|
582
|
+
safety: Object.freeze({
|
|
583
|
+
allowedHosts: Object.freeze(allowedHosts),
|
|
584
|
+
blockedEnvironmentVariables: Object.freeze(assertStringArray(safety.blockedEnvironmentVariables ??
|
|
585
|
+
REHEARSAL_DEFAULTS.safety.blockedEnvironmentVariables, "config.safety.blockedEnvironmentVariables")),
|
|
586
|
+
authenticationProviders: Object.freeze(authenticationProviders),
|
|
587
|
+
hostedAccess: "disabled",
|
|
588
|
+
outboundNetwork: "deny",
|
|
589
|
+
}),
|
|
590
|
+
verification: Object.freeze({
|
|
591
|
+
commands: Object.freeze(commands.map((command, index) => assertCommand(command, `config.verification.commands[${index}]`))),
|
|
592
|
+
}),
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
export const defineRehearsalConfig = (config) => config;
|
|
596
|
+
export const findRehearsalConfigPath = async ({ projectRoot = process.cwd(), configPath, } = {}) => {
|
|
597
|
+
const root = resolve(projectRoot);
|
|
598
|
+
if (configPath) {
|
|
599
|
+
const explicit = resolve(root, configPath);
|
|
600
|
+
if (relative(root, explicit).startsWith("..")) {
|
|
601
|
+
throw new Error("The Rehearsal configuration must stay inside the project root.");
|
|
602
|
+
}
|
|
603
|
+
await access(explicit);
|
|
604
|
+
return explicit;
|
|
605
|
+
}
|
|
606
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
607
|
+
const candidate = join(root, filename);
|
|
608
|
+
try {
|
|
609
|
+
await access(candidate);
|
|
610
|
+
return candidate;
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
throw new Error(`No Rehearsal configuration was found. Checked: ${CONFIG_FILENAMES.join(", ")}.`);
|
|
618
|
+
};
|
|
619
|
+
export const loadRehearsalConfig = async ({ projectRoot = process.cwd(), configPath, } = {}) => {
|
|
620
|
+
const root = resolve(projectRoot);
|
|
621
|
+
const path = await findRehearsalConfigPath({
|
|
622
|
+
projectRoot: root,
|
|
623
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
624
|
+
});
|
|
625
|
+
const module = await import(`${pathToFileURL(path).href}?loaded=${Date.now()}`);
|
|
626
|
+
const config = normalizeConfig(module.default);
|
|
627
|
+
const resolveOwnedPath = (value) => {
|
|
628
|
+
const resolved = resolve(root, value);
|
|
629
|
+
if (relative(root, resolved).startsWith("..")) {
|
|
630
|
+
throw new Error(`A Rehearsal path escaped the project root: ${value}.`);
|
|
631
|
+
}
|
|
632
|
+
return resolved;
|
|
633
|
+
};
|
|
634
|
+
const paths = Object.freeze({
|
|
635
|
+
artifactDirectory: resolveOwnedPath(config.baseline.artifactDirectory),
|
|
636
|
+
applicationEnvironment: resolveOwnedPath(config.application.environmentFile),
|
|
637
|
+
migrationDirectory: resolveOwnedPath((config.supabase ?? config.postgresql).migrationDirectory),
|
|
638
|
+
rehearsalConfig: config.supabase
|
|
639
|
+
? resolveOwnedPath(config.supabase.rehearsalConfig)
|
|
640
|
+
: null,
|
|
641
|
+
runtimeWorkdir: resolveOwnedPath((config.supabase ?? config.postgresql).runtimeWorkdir),
|
|
642
|
+
serviceEnvironment: config.supabase?.serviceEnvironmentFile
|
|
643
|
+
? resolveOwnedPath(config.supabase.serviceEnvironmentFile)
|
|
644
|
+
: null,
|
|
645
|
+
sanitizationPolicy: resolveOwnedPath(config.baseline.sanitizationPolicy),
|
|
646
|
+
sourcePolicy: config.preparation
|
|
647
|
+
? resolveOwnedPath(config.preparation.sourcePolicy)
|
|
648
|
+
: null,
|
|
649
|
+
privacyKey: config.preparation
|
|
650
|
+
? resolveOwnedPath(config.preparation.privacyKey)
|
|
651
|
+
: null,
|
|
652
|
+
runtimePolicy: config.runtimePolicy
|
|
653
|
+
? resolveOwnedPath(config.runtimePolicy)
|
|
654
|
+
: null,
|
|
655
|
+
identityPolicy: config.identityPolicy
|
|
656
|
+
? resolveOwnedPath(config.identityPolicy)
|
|
657
|
+
: null,
|
|
658
|
+
supabaseWorkdir: config.supabase
|
|
659
|
+
? resolveOwnedPath(config.supabase.workdir)
|
|
660
|
+
: null,
|
|
661
|
+
runtimeAdapter: config.application.runtimeAdapter
|
|
662
|
+
? resolveOwnedPath(config.application.runtimeAdapter)
|
|
663
|
+
: null,
|
|
664
|
+
});
|
|
665
|
+
if (basename(paths.artifactDirectory) !== ".rehearsal") {
|
|
666
|
+
throw new Error('config.baseline.artifactDirectory must resolve to a directory named ".rehearsal" so destructive reset and cleanup operations have an explicit safety boundary.');
|
|
667
|
+
}
|
|
668
|
+
const requiredRuntimeWorkdir = join(paths.artifactDirectory, "runtime");
|
|
669
|
+
if (paths.runtimeWorkdir !== requiredRuntimeWorkdir) {
|
|
670
|
+
throw new Error(`config.${config.runtime.target}.runtimeWorkdir must resolve to the runtime directory inside config.baseline.artifactDirectory.`);
|
|
671
|
+
}
|
|
672
|
+
const environmentRelativePath = relative(paths.artifactDirectory, paths.applicationEnvironment);
|
|
673
|
+
if (!environmentRelativePath ||
|
|
674
|
+
environmentRelativePath.startsWith("..") ||
|
|
675
|
+
isAbsolute(environmentRelativePath)) {
|
|
676
|
+
throw new Error("config.application.environmentFile must resolve to a file inside config.baseline.artifactDirectory.");
|
|
677
|
+
}
|
|
678
|
+
if (paths.privacyKey) {
|
|
679
|
+
const privacyKeyRelativePath = relative(paths.artifactDirectory, paths.privacyKey);
|
|
680
|
+
if (!privacyKeyRelativePath ||
|
|
681
|
+
privacyKeyRelativePath.startsWith("..") ||
|
|
682
|
+
isAbsolute(privacyKeyRelativePath)) {
|
|
683
|
+
throw new Error("config.preparation.privacyKey must resolve to a secret file inside config.baseline.artifactDirectory.");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (!IDENTIFIER_PATTERN.test(config.runtime.projectId)) {
|
|
687
|
+
throw new Error("config.runtime.projectId must contain only lowercase letters, digits, and hyphens.");
|
|
688
|
+
}
|
|
689
|
+
return Object.freeze({
|
|
690
|
+
config,
|
|
691
|
+
configPath: path,
|
|
692
|
+
projectRoot: root,
|
|
693
|
+
paths,
|
|
694
|
+
});
|
|
695
|
+
};
|
|
696
|
+
export const inspectDetectedProject = async ({ projectRoot = process.cwd(), } = {}) => {
|
|
697
|
+
const root = resolve(projectRoot);
|
|
698
|
+
const packageJsonPath = join(root, "package.json");
|
|
699
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
700
|
+
const hasPath = async (path) => stat(join(root, path))
|
|
701
|
+
.then(() => true)
|
|
702
|
+
.catch((error) => {
|
|
703
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
704
|
+
return false;
|
|
705
|
+
throw error;
|
|
706
|
+
});
|
|
707
|
+
const packageManager = (await hasPath("pnpm-lock.yaml"))
|
|
708
|
+
? "pnpm"
|
|
709
|
+
: (await hasPath("yarn.lock"))
|
|
710
|
+
? "yarn"
|
|
711
|
+
: "npm";
|
|
712
|
+
const scripts = packageJson.scripts ?? {};
|
|
713
|
+
const postgresqlMigrationDirectory = (await Promise.all(["database/migrations", "db/migrations", "migrations"].map(async (path) => ({ path, exists: await hasPath(path) })))).find(({ exists }) => exists)?.path;
|
|
714
|
+
const normalizedProjectName = String(packageJson.name ?? basename(root))
|
|
715
|
+
.toLowerCase()
|
|
716
|
+
.replace(/[^a-z0-9-]+/gu, "-")
|
|
717
|
+
.replace(/^-|-$/gu, "");
|
|
718
|
+
const projectName = normalizedProjectName.slice(0, 52).replace(/-$/u, "") ||
|
|
719
|
+
"rehearsal-project";
|
|
720
|
+
return {
|
|
721
|
+
projectName,
|
|
722
|
+
packageManager,
|
|
723
|
+
hasSupabaseConfig: await hasPath("supabase/config.toml"),
|
|
724
|
+
hasMigrations: await hasPath("supabase/migrations"),
|
|
725
|
+
hasPostgresqlMigrations: Boolean(postgresqlMigrationDirectory),
|
|
726
|
+
postgresqlMigrationDirectory: postgresqlMigrationDirectory ?? "database/migrations",
|
|
727
|
+
applicationCommand: ["dev:rehearsal", "dev", "start"]
|
|
728
|
+
.find((name) => typeof scripts[name] === "string")
|
|
729
|
+
?.replace(/^/u, `${packageManager} run `) ??
|
|
730
|
+
`${packageManager} run dev`,
|
|
731
|
+
verificationCommand: ["verify:feature", "test", "check"]
|
|
732
|
+
.find((name) => typeof scripts[name] === "string")
|
|
733
|
+
?.replace(/^/u, `${packageManager} run `) ?? `${packageManager} test`,
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
export const renderDetectedConfig = (detected, { applicationUrl = "http://localhost:5175", ports = { api: 58321, database: 58322, studio: 58323 }, } = {}) => `// @ts-check
|
|
737
|
+
/**
|
|
738
|
+
* Rehearsal configuration for ${detected.projectName}.
|
|
739
|
+
* Generated from this project by \`npx rehearsal\` and safe to commit.
|
|
740
|
+
* Review the CHECK comments. Keep passwords, tokens, and production URLs out of this file.
|
|
741
|
+
* Docs: https://github.com/Ddupasquier/rehearsal-db/blob/main/docs/configuration.md
|
|
742
|
+
*/
|
|
743
|
+
import { defineRehearsalConfig } from "@rehearsal-db/core";
|
|
744
|
+
|
|
745
|
+
export default defineRehearsalConfig({
|
|
746
|
+
// Configuration format. Rehearsal will explain if an upgrade is ever needed.
|
|
747
|
+
schemaVersion: 1,
|
|
748
|
+
// Stable local name used in Rehearsal labels and reports.
|
|
749
|
+
project: { name: ${JSON.stringify(detected.projectName)} },
|
|
750
|
+
|
|
751
|
+
// Project files Rehearsal reads. Every path stays inside this repository.
|
|
752
|
+
supabase: {
|
|
753
|
+
workdir: ".",
|
|
754
|
+
migrationDirectory: "supabase/migrations",
|
|
755
|
+
rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
|
|
756
|
+
runtimeWorkdir: ".rehearsal/runtime",
|
|
757
|
+
|
|
758
|
+
// Optional local OAuth connection. Secrets stay in the ignored owner-only file.
|
|
759
|
+
// Register http://127.0.0.1:${ports.api}/auth/v1/callback with the provider.
|
|
760
|
+
// authentication: {
|
|
761
|
+
// enableLocalSignup: true,
|
|
762
|
+
// environmentFile: ".env.rehearsal-service.local",
|
|
763
|
+
// providers: [
|
|
764
|
+
// {
|
|
765
|
+
// name: "google",
|
|
766
|
+
// clientIdEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_ID",
|
|
767
|
+
// clientSecretEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_SECRET",
|
|
768
|
+
// skipNonceCheck: false,
|
|
769
|
+
// emailOptional: false,
|
|
770
|
+
// },
|
|
771
|
+
// ],
|
|
772
|
+
// },
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
// Immutable local baseline files. Rehearsal never reads production on your behalf.
|
|
776
|
+
baseline: {
|
|
777
|
+
artifactDirectory: ".rehearsal",
|
|
778
|
+
sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
// Optional source refresh. Enable only after reviewing docs/standalone-workflow.md.
|
|
782
|
+
// Credentials are read from the environment named by source-access-policy.json.
|
|
783
|
+
// preparation: {
|
|
784
|
+
// sourcePolicy: "infrastructure/rehearsal/source-access-policy.json",
|
|
785
|
+
// privacyKey: ".rehearsal/secrets/privacy.key",
|
|
786
|
+
// batchRows: 500,
|
|
787
|
+
// maximumRows: 1000000,
|
|
788
|
+
// maximumBytes: 2147483648,
|
|
789
|
+
// diskHeadroomBytes: 67108864,
|
|
790
|
+
// },
|
|
791
|
+
// Optional reviewed schemas/extensions/triggers/local-only rows.
|
|
792
|
+
// runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
|
|
793
|
+
// Optional copied-account association. The JSON stores only reviewed matcher hashes;
|
|
794
|
+
// raw emails/subjects and provider credentials stay in ignored environment files.
|
|
795
|
+
// identityPolicy: "infrastructure/rehearsal/identity-policy.json",
|
|
796
|
+
|
|
797
|
+
// Reuse any running Docker-compatible engine; start Colima only when needed.
|
|
798
|
+
containerRuntime: {
|
|
799
|
+
autoStartColima: true,
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
// Keep this many baseline generations, always including the active one.
|
|
803
|
+
cleanup: {
|
|
804
|
+
retainBaselineGenerations: 2,
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
// Optional: manage another isolated database as part of the same rehearsal.
|
|
808
|
+
// Give that database its own complete config, ports, project ID, and .rehearsal folder.
|
|
809
|
+
// dependentTargets: [
|
|
810
|
+
// {
|
|
811
|
+
// name: "publication-api",
|
|
812
|
+
// configPath: "rehearsal.publication.config.mjs",
|
|
813
|
+
// prepareCommand: "npm run rehearsal:prepare-publication",
|
|
814
|
+
// },
|
|
815
|
+
// ],
|
|
816
|
+
|
|
817
|
+
application: {
|
|
818
|
+
// CHECK: commands detected from package.json. Change them if they are not correct.
|
|
819
|
+
startCommand: ${JSON.stringify(detected.applicationCommand)},
|
|
820
|
+
proofCommand: ${JSON.stringify(detected.verificationCommand)},
|
|
821
|
+
environmentFile: ".rehearsal/runtime.env",
|
|
822
|
+
// Map generated local runtime values directly into the normal app command.
|
|
823
|
+
// environmentVariables: { DATABASE_URL: "primary:DATABASE_URL" },
|
|
824
|
+
// Required by \`rehearsal open\`; use a route that responds only when the app is ready.
|
|
825
|
+
readiness: { url: ${JSON.stringify(applicationUrl)}, expectedStatus: 200, timeoutSeconds: 30 },
|
|
826
|
+
// Add at least one real positive and one negative before enabling HTTP proofs.
|
|
827
|
+
// httpProofs: [],
|
|
828
|
+
// Optional project-specific restore hook:
|
|
829
|
+
// runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
// Disposable local runtime identity, URL, and dedicated ports.
|
|
833
|
+
runtime: {
|
|
834
|
+
target: "supabase",
|
|
835
|
+
applicationUrl: ${JSON.stringify(applicationUrl)},
|
|
836
|
+
projectId: "${detected.projectName}-rehearsal",
|
|
837
|
+
apiPort: ${ports.api},
|
|
838
|
+
databasePort: ${ports.database},
|
|
839
|
+
studioPort: ${ports.studio},
|
|
840
|
+
},
|
|
841
|
+
|
|
842
|
+
// Only local runtime URLs are accepted; common hosted credentials are quarantined.
|
|
843
|
+
safety: {
|
|
844
|
+
allowedHosts: ["127.0.0.1", "::1", "localhost"],
|
|
845
|
+
blockedEnvironmentVariables: [
|
|
846
|
+
"SUPABASE_ACCESS_TOKEN",
|
|
847
|
+
"SUPABASE_DB_PASSWORD",
|
|
848
|
+
"SUPABASE_PROJECT_ID",
|
|
849
|
+
],
|
|
850
|
+
hostedAccess: "disabled",
|
|
851
|
+
outboundNetwork: "deny",
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
`.replaceAll("\t", " ");
|
|
855
|
+
export const renderDetectedPostgresqlConfig = (detected, { applicationUrl = "http://localhost:5175", databasePort = 58322 } = {}) => `// @ts-check
|
|
856
|
+
/**
|
|
857
|
+
* Rehearsal configuration for ${detected.projectName}.
|
|
858
|
+
* Generated from this project by \`npx rehearsal\` and safe to commit.
|
|
859
|
+
* Review the CHECK comments. Keep passwords, tokens, and production URLs out of this file.
|
|
860
|
+
* Docs: https://github.com/Ddupasquier/rehearsal-db/blob/main/docs/configuration.md
|
|
861
|
+
*/
|
|
862
|
+
import { defineRehearsalConfig } from "@rehearsal-db/core";
|
|
863
|
+
|
|
864
|
+
export default defineRehearsalConfig({
|
|
865
|
+
// Configuration format. Rehearsal will explain if an upgrade is ever needed.
|
|
866
|
+
schemaVersion: 1,
|
|
867
|
+
// Stable local name used in Rehearsal labels and reports.
|
|
868
|
+
project: { name: ${JSON.stringify(detected.projectName)} },
|
|
869
|
+
|
|
870
|
+
// Disposable PostgreSQL settings. The image must already exist locally.
|
|
871
|
+
postgresql: {
|
|
872
|
+
migrationDirectory: ${JSON.stringify(detected.postgresqlMigrationDirectory)},
|
|
873
|
+
runtimeWorkdir: ".rehearsal/runtime",
|
|
874
|
+
image: "postgres:17-alpine",
|
|
875
|
+
database: "postgres",
|
|
876
|
+
user: "postgres",
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
// Immutable local baseline files. Rehearsal never reads production on your behalf.
|
|
880
|
+
baseline: {
|
|
881
|
+
artifactDirectory: ".rehearsal",
|
|
882
|
+
sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
// Optional source refresh. Enable only after reviewing docs/standalone-workflow.md.
|
|
886
|
+
// Credentials are read from the environment named by source-access-policy.json.
|
|
887
|
+
// preparation: {
|
|
888
|
+
// sourcePolicy: "infrastructure/rehearsal/source-access-policy.json",
|
|
889
|
+
// privacyKey: ".rehearsal/secrets/privacy.key",
|
|
890
|
+
// batchRows: 500,
|
|
891
|
+
// maximumRows: 1000000,
|
|
892
|
+
// maximumBytes: 2147483648,
|
|
893
|
+
// diskHeadroomBytes: 67108864,
|
|
894
|
+
// },
|
|
895
|
+
// Optional reviewed schemas/extensions/triggers/local-only rows.
|
|
896
|
+
// runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
|
|
897
|
+
// Optional copied-account association. The JSON stores only reviewed matcher hashes;
|
|
898
|
+
// raw emails/subjects and provider credentials stay in ignored environment files.
|
|
899
|
+
// identityPolicy: "infrastructure/rehearsal/identity-policy.json",
|
|
900
|
+
|
|
901
|
+
// Reuse any running Docker-compatible engine; start Colima only when needed.
|
|
902
|
+
containerRuntime: {
|
|
903
|
+
autoStartColima: true,
|
|
904
|
+
},
|
|
905
|
+
|
|
906
|
+
// Keep this many baseline generations, always including the active one.
|
|
907
|
+
cleanup: {
|
|
908
|
+
retainBaselineGenerations: 2,
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
// Optional: manage another isolated database as part of the same rehearsal.
|
|
912
|
+
// Give that database its own complete config, ports, project ID, and .rehearsal folder.
|
|
913
|
+
// dependentTargets: [
|
|
914
|
+
// {
|
|
915
|
+
// name: "publication-api",
|
|
916
|
+
// configPath: "rehearsal.publication.config.mjs",
|
|
917
|
+
// prepareCommand: "npm run rehearsal:prepare-publication",
|
|
918
|
+
// },
|
|
919
|
+
// ],
|
|
920
|
+
|
|
921
|
+
application: {
|
|
922
|
+
// CHECK: commands detected from package.json. Change them if they are not correct.
|
|
923
|
+
startCommand: ${JSON.stringify(detected.applicationCommand)},
|
|
924
|
+
proofCommand: ${JSON.stringify(detected.verificationCommand)},
|
|
925
|
+
environmentFile: ".rehearsal/runtime.env",
|
|
926
|
+
// Map generated local runtime values directly into the normal app command.
|
|
927
|
+
// environmentVariables: { DATABASE_URL: "primary:DATABASE_URL" },
|
|
928
|
+
// Required by \`rehearsal open\`; use a route that responds only when the app is ready.
|
|
929
|
+
readiness: { url: ${JSON.stringify(applicationUrl)}, expectedStatus: 200, timeoutSeconds: 30 },
|
|
930
|
+
// Add at least one real positive and one negative before enabling HTTP proofs.
|
|
931
|
+
// httpProofs: [],
|
|
932
|
+
// Optional project-specific restore hook:
|
|
933
|
+
// runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
// Disposable local runtime identity, URL, and dedicated database port.
|
|
937
|
+
runtime: {
|
|
938
|
+
target: "postgresql",
|
|
939
|
+
applicationUrl: ${JSON.stringify(applicationUrl)},
|
|
940
|
+
projectId: "${detected.projectName}-rehearsal",
|
|
941
|
+
databasePort: ${databasePort},
|
|
942
|
+
},
|
|
943
|
+
|
|
944
|
+
// Only local runtime URLs are accepted; common hosted credentials are quarantined.
|
|
945
|
+
safety: {
|
|
946
|
+
allowedHosts: ["127.0.0.1", "::1", "localhost"],
|
|
947
|
+
blockedEnvironmentVariables: [
|
|
948
|
+
"DATABASE_URL",
|
|
949
|
+
"PGHOST",
|
|
950
|
+
"PGPASSWORD",
|
|
951
|
+
"PGPORT",
|
|
952
|
+
"PGUSER",
|
|
953
|
+
],
|
|
954
|
+
hostedAccess: "disabled",
|
|
955
|
+
outboundNetwork: "deny",
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
`.replaceAll("\t", " ");
|