@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.
- package/CHANGELOG.md +77 -1
- package/COMPATIBILITY.md +15 -3
- package/README.md +31 -13
- package/docs/README.md +29 -0
- package/docs/adapters.md +15 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +22 -1
- package/docs/commands.md +81 -14
- package/docs/configuration.md +144 -11
- package/docs/getting-started.md +12 -2
- package/docs/glossary.md +4 -0
- package/docs/production-source.md +176 -33
- package/docs/roadmap.md +30 -28
- package/docs/runtime-policies.md +191 -0
- package/docs/sanitization.md +24 -8
- package/docs/security-model.md +29 -8
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +8 -0
- package/package.json +25 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +313 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
- package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +120 -0
- package/src/cli/guided.mjs +807 -0
- package/src/cli/rehearsal.mjs +933 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +553 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
- package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
- package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
- package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
- package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
- package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
- package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
- /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
- /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 "../
|
|
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 "
|
|
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
|
-
[
|
|
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 "../
|
|
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 {
|
|
10
|
-
|
|
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";
|