@rehearsal-db/core 0.1.0-beta.11 → 0.1.0-beta.13
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 +6 -0
- package/README.md +11 -2
- package/docs/commands.md +18 -13
- package/docs/configuration.md +78 -12
- package/docs/getting-started.md +14 -4
- package/docs/production-source.md +138 -16
- package/docs/runtime-policies.md +4 -2
- package/docs/sanitization.md +73 -7
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +24 -9
- package/docs/troubleshooting.md +26 -8
- package/docs/tutorial.md +2 -1
- package/package.json +4 -3
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +220 -9
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/init_command.mjs +17 -0
- package/src/cli/rehearsal.mjs +20 -2
- package/src/cli/renderers.mjs +41 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +26 -5
- package/src/identity/provider_configuration.mjs +177 -0
- package/src/project/configuration.d.mts +11 -0
- package/src/project/configuration.mjs +155 -19
- package/src/project/setup.mjs +5 -23
- package/src/runtime/plan.mjs +40 -3
- package/src/runtime/restore.mjs +13 -5
- package/src/shared/diagnostics.mjs +7 -0
- package/src/shared/node_runtime.mjs +31 -0
- package/src/source/access.mjs +224 -49
- package/src/source/asset_transfer.mjs +35 -5
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +255 -0
- package/src/source/postgresql_access.mjs +143 -42
- package/src/source/privacy_paths.mjs +38 -0
- package/src/targets/supabase.mjs +11 -1
|
@@ -211,6 +211,82 @@ const projectCommandEnvironment = (additional = {}) => ({
|
|
|
211
211
|
...additional,
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
const RUNTIME_STARTUP_FAILURE_PATTERN =
|
|
215
|
+
/container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu;
|
|
216
|
+
|
|
217
|
+
export const classifyManagerFailure = ({
|
|
218
|
+
action,
|
|
219
|
+
output,
|
|
220
|
+
inferredCategory,
|
|
221
|
+
}) => {
|
|
222
|
+
if (RUNTIME_STARTUP_FAILURE_PATTERN.test(output)) {
|
|
223
|
+
return "runtime_dependency_failure";
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
action === "migrate" ||
|
|
227
|
+
(action === "run" &&
|
|
228
|
+
/migrateRuntime|candidate migration|migration up/iu.test(output))
|
|
229
|
+
) {
|
|
230
|
+
return "migration_candidate_failure";
|
|
231
|
+
}
|
|
232
|
+
return inferredCategory;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const parseDockerCapacity = (output) => {
|
|
236
|
+
const [cpuText, memoryText, runningText, totalText] = output
|
|
237
|
+
.trim()
|
|
238
|
+
.split(/\s+/u);
|
|
239
|
+
const values = [cpuText, memoryText, runningText, totalText].map(Number);
|
|
240
|
+
if (!values.every(Number.isFinite)) return null;
|
|
241
|
+
const [cpus, memoryBytes, runningContainers, containers] = values;
|
|
242
|
+
return { cpus, memoryBytes, runningContainers, containers };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const parseColimaMemory = (output) => {
|
|
246
|
+
const line = output
|
|
247
|
+
.split("\n")
|
|
248
|
+
.map((entry) => entry.trim())
|
|
249
|
+
.find((entry) => entry.startsWith("Mem:"));
|
|
250
|
+
if (!line) return null;
|
|
251
|
+
const columns = line.split(/\s+/u);
|
|
252
|
+
const totalBytes = Number(columns[1]);
|
|
253
|
+
const availableBytes = Number(columns[6]);
|
|
254
|
+
if (![totalBytes, availableBytes].every(Number.isFinite)) return null;
|
|
255
|
+
return { totalBytes, availableBytes };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const diagnosticCommand = (command, args) =>
|
|
259
|
+
spawnSync(command, args, {
|
|
260
|
+
encoding: "utf8",
|
|
261
|
+
env: projectCommandEnvironment(),
|
|
262
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
263
|
+
timeout: 5_000,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const inspectContainerCapacity = () => {
|
|
267
|
+
const docker = diagnosticCommand("docker", [
|
|
268
|
+
"info",
|
|
269
|
+
"--format",
|
|
270
|
+
"{{.NCPU}} {{.MemTotal}} {{.ContainersRunning}} {{.Containers}}",
|
|
271
|
+
]);
|
|
272
|
+
const capacity =
|
|
273
|
+
docker.status === 0 ? parseDockerCapacity(docker.stdout) : null;
|
|
274
|
+
if (!capacity) return undefined;
|
|
275
|
+
|
|
276
|
+
const context = diagnosticCommand("docker", ["context", "show"]);
|
|
277
|
+
if (context.status !== 0 || context.stdout.trim() !== "colima") {
|
|
278
|
+
return { engine: "docker-compatible", ...capacity };
|
|
279
|
+
}
|
|
280
|
+
const memory = diagnosticCommand("colima", ["ssh", "--", "free", "-b"]);
|
|
281
|
+
return {
|
|
282
|
+
engine: "colima",
|
|
283
|
+
...capacity,
|
|
284
|
+
...(memory.status === 0
|
|
285
|
+
? { runtimeMemory: parseColimaMemory(memory.stdout) ?? undefined }
|
|
286
|
+
: {}),
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
214
290
|
export const topologyCommandEnvironment = (topology) =>
|
|
215
291
|
Object.fromEntries(
|
|
216
292
|
topology.targets.map((target) => [
|
|
@@ -344,14 +420,15 @@ export const createRuntimeCommands = ({
|
|
|
344
420
|
const inferred = normalizeRehearsalError(
|
|
345
421
|
new Error(completeOutput || `Rehearsal ${action} failed.`),
|
|
346
422
|
);
|
|
347
|
-
const category =
|
|
348
|
-
action
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
423
|
+
const category = classifyManagerFailure({
|
|
424
|
+
action,
|
|
425
|
+
output: completeOutput,
|
|
426
|
+
inferredCategory: inferred.category,
|
|
427
|
+
});
|
|
428
|
+
const startupFailure = category === "runtime_dependency_failure";
|
|
429
|
+
const engineCapacity = startupFailure
|
|
430
|
+
? inspectContainerCapacity()
|
|
431
|
+
: undefined;
|
|
355
432
|
const conciseOutput = completeOutput
|
|
356
433
|
.replaceAll(String.fromCodePoint(27), "")
|
|
357
434
|
.split("\n")
|
|
@@ -366,10 +443,16 @@ export const createRuntimeCommands = ({
|
|
|
366
443
|
message: `The local Rehearsal ${action} operation did not complete.`,
|
|
367
444
|
expected: "the isolated runtime operation to finish and verify",
|
|
368
445
|
actual: conciseOutput || `exit ${result.status}`,
|
|
369
|
-
context: { action },
|
|
446
|
+
context: { action, ...(engineCapacity ? { engineCapacity } : {}) },
|
|
370
447
|
refused: "The runtime was not reported as trusted.",
|
|
371
448
|
suggestions: [
|
|
372
449
|
"Review the concise failure above, then rerun rehearsal doctor before retrying.",
|
|
450
|
+
...(startupFailure
|
|
451
|
+
? [
|
|
452
|
+
"Stop unrelated local stacks or increase Docker/Colima capacity, then retry. Rehearsal will not change engine settings or bypass service health checks.",
|
|
453
|
+
"Run docker stats --no-stream to identify active containers using the shared engine.",
|
|
454
|
+
]
|
|
455
|
+
: []),
|
|
373
456
|
"Use --debug only when the safe diagnostic detail is needed.",
|
|
374
457
|
],
|
|
375
458
|
cause: new Error(completeOutput || `exit ${result.status}`),
|
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
pruneBaselineGenerations,
|
|
14
14
|
verifyActiveBaseline,
|
|
15
15
|
} from "../baseline/artifact.mjs";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createPrivacyEngine,
|
|
18
|
+
readPrivacyKey,
|
|
19
|
+
} from "../baseline/privacy_engine.mjs";
|
|
17
20
|
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
18
21
|
import {
|
|
19
22
|
createSourceAccessPlan,
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
} from "../source/access.mjs";
|
|
22
25
|
import { streamApprovedSupabaseAssets } from "../source/asset_transfer.mjs";
|
|
23
26
|
import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
|
|
27
|
+
import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
|
|
24
28
|
import {
|
|
25
29
|
applyPostgresqlSourceAccess,
|
|
26
30
|
planPostgresqlSourceAccessRetirement,
|
|
@@ -114,6 +118,15 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
114
118
|
join(loaded.projectRoot, sourcePolicy.reader.credentialFile),
|
|
115
119
|
),
|
|
116
120
|
]);
|
|
121
|
+
const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
|
|
122
|
+
const assetPrivacyEngine = createPrivacyEngine({
|
|
123
|
+
policy: privacyPolicy,
|
|
124
|
+
key: privacyKey,
|
|
125
|
+
});
|
|
126
|
+
const assetDeclarations = resolvePrivacyMappedAssets({
|
|
127
|
+
sourcePolicy,
|
|
128
|
+
privacyPolicy,
|
|
129
|
+
});
|
|
117
130
|
return refreshPostgresqlBaseline({
|
|
118
131
|
connectionString,
|
|
119
132
|
sourcePolicy,
|
|
@@ -133,10 +146,12 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
133
146
|
baseUrl:
|
|
134
147
|
process.env[sourcePolicy.assetReader.baseUrlEnvironmentVariable],
|
|
135
148
|
token: process.env[sourcePolicy.assetReader.tokenEnvironmentVariable],
|
|
136
|
-
declarations:
|
|
149
|
+
declarations: assetDeclarations,
|
|
137
150
|
maximumObjects: sourcePolicy.assetReader.maximumObjects,
|
|
138
151
|
maximumObjectBytes: sourcePolicy.assetReader.maximumObjectBytes,
|
|
139
152
|
maximumTotalBytes: sourcePolicy.assetReader.maximumTotalBytes,
|
|
153
|
+
pathMapper: ({ mapping, value }) =>
|
|
154
|
+
assetPrivacyEngine.remapPath({ mapping, value }),
|
|
140
155
|
})
|
|
141
156
|
: [],
|
|
142
157
|
});
|
|
@@ -244,9 +259,15 @@ export const buildRefreshWorkflowPlan = async (planOptions) => {
|
|
|
244
259
|
};
|
|
245
260
|
|
|
246
261
|
export const createRefreshWorkflow =
|
|
247
|
-
({
|
|
262
|
+
({
|
|
263
|
+
runRuntimeStack,
|
|
264
|
+
prepareDependentTargets,
|
|
265
|
+
topologyCommandEnvironment,
|
|
266
|
+
buildPlan = buildRefreshWorkflowPlan,
|
|
267
|
+
refreshBaseline = runSourceBaselineRefresh,
|
|
268
|
+
}) =>
|
|
248
269
|
async ({ flags, planOptions }) => {
|
|
249
|
-
const context = await
|
|
270
|
+
const context = await buildPlan(planOptions);
|
|
250
271
|
if (!flags.refreshConfirmation) {
|
|
251
272
|
return { mode: "preview", plan: context.plan };
|
|
252
273
|
}
|
|
@@ -259,7 +280,7 @@ export const createRefreshWorkflow =
|
|
|
259
280
|
let runtime;
|
|
260
281
|
let preparations = [];
|
|
261
282
|
try {
|
|
262
|
-
refreshed = await
|
|
283
|
+
refreshed = await refreshBaseline({ planOptions });
|
|
263
284
|
const stack = await runRuntimeStack({
|
|
264
285
|
command: "reset",
|
|
265
286
|
flags,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Render reviewed local OAuth declarations into the disposable Supabase runtime. */
|
|
2
|
+
|
|
3
|
+
const escapePattern = (value) =>
|
|
4
|
+
value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
5
|
+
|
|
6
|
+
const LEGACY_PROVIDER_KEYS = new Set([
|
|
7
|
+
"client_id",
|
|
8
|
+
"email_optional",
|
|
9
|
+
"enabled",
|
|
10
|
+
"redirect_uri",
|
|
11
|
+
"secret",
|
|
12
|
+
"skip_nonce_check",
|
|
13
|
+
]);
|
|
14
|
+
const SAFE_BOOLEAN_PROVIDER_KEYS = new Set([
|
|
15
|
+
"email_optional",
|
|
16
|
+
"skip_nonce_check",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const readTomlSections = (source) => {
|
|
20
|
+
const lines = source.split("\n");
|
|
21
|
+
const sections = [];
|
|
22
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
23
|
+
const match = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/u.exec(lines[index]);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
if (sections.length) sections.at(-1).end = index;
|
|
26
|
+
sections.push({ name: match[1], start: index, end: lines.length });
|
|
27
|
+
}
|
|
28
|
+
return { lines, sections };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const validateLegacyProviderSection = ({ lines, section, provider }) => {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const line of lines.slice(section.start + 1, section.end)) {
|
|
34
|
+
if (!line.trim() || /^\s*#/u.test(line)) continue;
|
|
35
|
+
const assignment = /^\s*([A-Za-z0-9_]+)\s*=/u.exec(line);
|
|
36
|
+
if (!assignment || !LEGACY_PROVIDER_KEYS.has(assignment[1])) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`The local Supabase template auth.external.${provider.name} section contains an unsupported setting. Rehearsal left the tracked template unchanged.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const key = assignment[1];
|
|
42
|
+
if (seen.has(key)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`The local Supabase template auth.external.${provider.name} section contains duplicate ${key} settings.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
seen.add(key);
|
|
48
|
+
if (
|
|
49
|
+
SAFE_BOOLEAN_PROVIDER_KEYS.has(key) &&
|
|
50
|
+
!new RegExp(
|
|
51
|
+
`^\\s*${escapePattern(key)}\\s*=\\s*${
|
|
52
|
+
key === "skip_nonce_check"
|
|
53
|
+
? (provider.skipNonceCheck ?? false)
|
|
54
|
+
: (provider.emailOptional ?? false)
|
|
55
|
+
}\\s*(?:#.*)?$`,
|
|
56
|
+
"u",
|
|
57
|
+
).test(line)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`The local Supabase template auth.external.${provider.name}.${key} setting must match its explicit declarative authentication option before Rehearsal can replace the legacy provider in its disposable runtime copy.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const inspectSupabaseAuthenticationProviderTransition = ({
|
|
67
|
+
source,
|
|
68
|
+
authentication,
|
|
69
|
+
}) => {
|
|
70
|
+
if (!authentication) return Object.freeze({ replacedProviders: [] });
|
|
71
|
+
const { lines, sections } = readTomlSections(source);
|
|
72
|
+
const replacementSections = [];
|
|
73
|
+
for (const provider of authentication.providers) {
|
|
74
|
+
const sectionName = `auth.external.${provider.name}`;
|
|
75
|
+
const nested = sections.filter((section) =>
|
|
76
|
+
section.name.startsWith(`${sectionName}.`),
|
|
77
|
+
);
|
|
78
|
+
if (nested.length) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`The local Supabase template contains unsupported nested ${sectionName} configuration. Rehearsal left the tracked template unchanged.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const matching = sections.filter((section) => section.name === sectionName);
|
|
84
|
+
if (matching.length > 1) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`The local Supabase template declares ${sectionName} more than once.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (matching.length === 1) {
|
|
90
|
+
validateLegacyProviderSection({
|
|
91
|
+
lines,
|
|
92
|
+
section: matching[0],
|
|
93
|
+
provider,
|
|
94
|
+
});
|
|
95
|
+
replacementSections.push({ ...matching[0], provider: provider.name });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
replacedProviders: Object.freeze(
|
|
100
|
+
replacementSections.map(({ provider }) => provider),
|
|
101
|
+
),
|
|
102
|
+
replacementSections: Object.freeze(replacementSections),
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const renderSupabaseAuthenticationProviders = ({
|
|
107
|
+
authentication,
|
|
108
|
+
apiPort,
|
|
109
|
+
}) => {
|
|
110
|
+
if (!authentication) return "";
|
|
111
|
+
return authentication.providers
|
|
112
|
+
.map(
|
|
113
|
+
(
|
|
114
|
+
provider,
|
|
115
|
+
) => `# Generated by Rehearsal from config.supabase.authentication.
|
|
116
|
+
[auth.external.${provider.name}]
|
|
117
|
+
enabled = true
|
|
118
|
+
client_id = "env(${provider.clientIdEnvironmentVariable})"
|
|
119
|
+
secret = "env(${provider.clientSecretEnvironmentVariable})"
|
|
120
|
+
redirect_uri = "http://127.0.0.1:${apiPort}/auth/v1/callback"
|
|
121
|
+
${provider.skipNonceCheck ? "skip_nonce_check = true\n" : ""}${provider.emailOptional ? "email_optional = true\n" : ""}
|
|
122
|
+
`,
|
|
123
|
+
)
|
|
124
|
+
.join("\n");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const enableLocalSignup = (source) => {
|
|
128
|
+
const lines = source.split("\n");
|
|
129
|
+
let inAuth = false;
|
|
130
|
+
let authSections = 0;
|
|
131
|
+
let signupSettings = 0;
|
|
132
|
+
const updated = lines.map((line) => {
|
|
133
|
+
const section = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/u.exec(line);
|
|
134
|
+
if (section) {
|
|
135
|
+
inAuth = section[1] === "auth";
|
|
136
|
+
if (inAuth) authSections += 1;
|
|
137
|
+
return line;
|
|
138
|
+
}
|
|
139
|
+
if (!inAuth || !/^\s*enable_signup\s*=/u.test(line)) return line;
|
|
140
|
+
signupSettings += 1;
|
|
141
|
+
const comment = line.match(/\s+#.*$/u)?.[0] ?? "";
|
|
142
|
+
return `enable_signup = true${comment}`;
|
|
143
|
+
});
|
|
144
|
+
if (authSections !== 1 || signupSettings !== 1) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"The local Supabase template must declare exactly one [auth] section with one enable_signup setting before Rehearsal can configure OAuth.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return updated.join("\n");
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const applySupabaseAuthenticationProviders = ({
|
|
153
|
+
source,
|
|
154
|
+
authentication,
|
|
155
|
+
apiPort,
|
|
156
|
+
}) => {
|
|
157
|
+
if (!authentication) return source;
|
|
158
|
+
const transition = inspectSupabaseAuthenticationProviderTransition({
|
|
159
|
+
source,
|
|
160
|
+
authentication,
|
|
161
|
+
});
|
|
162
|
+
const { lines } = readTomlSections(source);
|
|
163
|
+
const replacedIndexes = new Set(
|
|
164
|
+
transition.replacementSections.flatMap((section) =>
|
|
165
|
+
Array.from(
|
|
166
|
+
{ length: section.end - section.start },
|
|
167
|
+
(_, offset) => section.start + offset,
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
const transitionedSource = lines
|
|
172
|
+
.filter((_, index) => !replacedIndexes.has(index))
|
|
173
|
+
.join("\n");
|
|
174
|
+
const runtimeSource = enableLocalSignup(transitionedSource);
|
|
175
|
+
const separator = runtimeSource.endsWith("\n") ? "\n" : "\n\n";
|
|
176
|
+
return `${runtimeSource}${separator}${renderSupabaseAuthenticationProviders({ authentication, apiPort })}`;
|
|
177
|
+
};
|
|
@@ -8,6 +8,17 @@ export interface RehearsalConfig {
|
|
|
8
8
|
migrationDirectory: string;
|
|
9
9
|
rehearsalConfig: string;
|
|
10
10
|
runtimeWorkdir: string;
|
|
11
|
+
authentication?: {
|
|
12
|
+
enableLocalSignup: true;
|
|
13
|
+
environmentFile: string;
|
|
14
|
+
providers: Array<{
|
|
15
|
+
name: "google" | "github";
|
|
16
|
+
clientIdEnvironmentVariable: string;
|
|
17
|
+
clientSecretEnvironmentVariable: string;
|
|
18
|
+
skipNonceCheck?: boolean;
|
|
19
|
+
emailOptional?: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
11
22
|
serviceEnvironmentFile?: string;
|
|
12
23
|
serviceEnvironmentVariables?: string[];
|
|
13
24
|
};
|
|
@@ -61,6 +61,7 @@ const POSTGRES_IMAGE_PATTERN = /^postgres:\d+(?:\.\d+)?-alpine$/u;
|
|
|
61
61
|
const SAFE_COMMAND_PATTERN = /^[^\n\r\0]+$/u;
|
|
62
62
|
const ENVIRONMENT_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/u;
|
|
63
63
|
const ENVIRONMENT_REFERENCE_PATTERN = /^[a-z0-9][a-z0-9-]*:[A-Z][A-Z0-9_]*$/u;
|
|
64
|
+
const SUPPORTED_SUPABASE_OAUTH_PROVIDERS = new Set(["github", "google"]);
|
|
64
65
|
|
|
65
66
|
const isPlainObject = (value) =>
|
|
66
67
|
value !== null &&
|
|
@@ -251,6 +252,7 @@ const normalizeConfig = (input) => {
|
|
|
251
252
|
"migrationDirectory",
|
|
252
253
|
"rehearsalConfig",
|
|
253
254
|
"runtimeWorkdir",
|
|
255
|
+
"authentication",
|
|
254
256
|
"serviceEnvironmentFile",
|
|
255
257
|
"serviceEnvironmentVariables",
|
|
256
258
|
],
|
|
@@ -437,8 +439,123 @@ const normalizeConfig = (input) => {
|
|
|
437
439
|
if (!Array.isArray(commands)) {
|
|
438
440
|
throw new Error("config.verification.commands must be an array.");
|
|
439
441
|
}
|
|
440
|
-
const
|
|
441
|
-
supabase?.
|
|
442
|
+
const authentication =
|
|
443
|
+
supabase?.authentication === undefined
|
|
444
|
+
? null
|
|
445
|
+
: assertPlainObject(
|
|
446
|
+
supabase.authentication,
|
|
447
|
+
"config.supabase.authentication",
|
|
448
|
+
);
|
|
449
|
+
let normalizedAuthentication = null;
|
|
450
|
+
if (authentication) {
|
|
451
|
+
assertKnownKeys(
|
|
452
|
+
authentication,
|
|
453
|
+
["enableLocalSignup", "environmentFile", "providers"],
|
|
454
|
+
"config.supabase.authentication",
|
|
455
|
+
);
|
|
456
|
+
if (authentication.enableLocalSignup !== true) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
"config.supabase.authentication.enableLocalSignup must be true to create the disposable local identity used by an OAuth sign-in.",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (
|
|
462
|
+
supabase.serviceEnvironmentFile !== undefined ||
|
|
463
|
+
supabase.serviceEnvironmentVariables !== undefined
|
|
464
|
+
) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
"config.supabase.authentication cannot be combined with the legacy serviceEnvironmentFile or serviceEnvironmentVariables fields.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (safety.authenticationProviders !== undefined) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
"config.safety.authenticationProviders is derived from config.supabase.authentication and must be omitted.",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (
|
|
475
|
+
!Array.isArray(authentication.providers) ||
|
|
476
|
+
authentication.providers.length === 0
|
|
477
|
+
) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
"config.supabase.authentication.providers must be a non-empty array.",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const providers = authentication.providers.map((entry, index) => {
|
|
483
|
+
const path = `config.supabase.authentication.providers[${index}]`;
|
|
484
|
+
const value = assertPlainObject(entry, path);
|
|
485
|
+
assertKnownKeys(
|
|
486
|
+
value,
|
|
487
|
+
[
|
|
488
|
+
"name",
|
|
489
|
+
"clientIdEnvironmentVariable",
|
|
490
|
+
"clientSecretEnvironmentVariable",
|
|
491
|
+
"skipNonceCheck",
|
|
492
|
+
"emailOptional",
|
|
493
|
+
],
|
|
494
|
+
path,
|
|
495
|
+
);
|
|
496
|
+
const name = assertNonEmptyString(value.name, `${path}.name`);
|
|
497
|
+
if (!SUPPORTED_SUPABASE_OAUTH_PROVIDERS.has(name)) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`${path}.name must be one of: ${[...SUPPORTED_SUPABASE_OAUTH_PROVIDERS].join(", ")}.`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const environmentVariable = (key) => {
|
|
503
|
+
const variable = assertNonEmptyString(value[key], `${path}.${key}`);
|
|
504
|
+
if (!ENVIRONMENT_KEY_PATTERN.test(variable)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`${path}.${key} must be a safe environment variable name.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return variable;
|
|
510
|
+
};
|
|
511
|
+
const optionalBoolean = (key) => {
|
|
512
|
+
if (value[key] === undefined) return false;
|
|
513
|
+
if (typeof value[key] !== "boolean") {
|
|
514
|
+
throw new Error(`${path}.${key} must be a boolean.`);
|
|
515
|
+
}
|
|
516
|
+
return value[key];
|
|
517
|
+
};
|
|
518
|
+
return Object.freeze({
|
|
519
|
+
name,
|
|
520
|
+
clientIdEnvironmentVariable: environmentVariable(
|
|
521
|
+
"clientIdEnvironmentVariable",
|
|
522
|
+
),
|
|
523
|
+
clientSecretEnvironmentVariable: environmentVariable(
|
|
524
|
+
"clientSecretEnvironmentVariable",
|
|
525
|
+
),
|
|
526
|
+
skipNonceCheck: optionalBoolean("skipNonceCheck"),
|
|
527
|
+
emailOptional: optionalBoolean("emailOptional"),
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
if (new Set(providers.map(({ name }) => name)).size !== providers.length) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
"config.supabase.authentication.providers must not contain duplicate providers.",
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
const credentialVariables = providers.flatMap((provider) => [
|
|
536
|
+
provider.clientIdEnvironmentVariable,
|
|
537
|
+
provider.clientSecretEnvironmentVariable,
|
|
538
|
+
]);
|
|
539
|
+
if (new Set(credentialVariables).size !== credentialVariables.length) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
"config.supabase.authentication provider environment variables must be unique.",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
normalizedAuthentication = Object.freeze({
|
|
545
|
+
enableLocalSignup: true,
|
|
546
|
+
environmentFile: assertRelativePath(
|
|
547
|
+
authentication.environmentFile,
|
|
548
|
+
"config.supabase.authentication.environmentFile",
|
|
549
|
+
),
|
|
550
|
+
providers: Object.freeze(providers),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const serviceEnvironmentVariables = normalizedAuthentication
|
|
554
|
+
? normalizedAuthentication.providers.flatMap((provider) => [
|
|
555
|
+
provider.clientIdEnvironmentVariable,
|
|
556
|
+
provider.clientSecretEnvironmentVariable,
|
|
557
|
+
])
|
|
558
|
+
: (supabase?.serviceEnvironmentVariables ?? []);
|
|
442
559
|
if (!Array.isArray(serviceEnvironmentVariables)) {
|
|
443
560
|
throw new Error(
|
|
444
561
|
"config.supabase.serviceEnvironmentVariables must be an array.",
|
|
@@ -460,19 +577,23 @@ const normalizeConfig = (input) => {
|
|
|
460
577
|
);
|
|
461
578
|
}
|
|
462
579
|
if (
|
|
463
|
-
Boolean(
|
|
464
|
-
|
|
580
|
+
Boolean(
|
|
581
|
+
normalizedAuthentication?.environmentFile ??
|
|
582
|
+
supabase?.serviceEnvironmentFile,
|
|
583
|
+
) !== Boolean(normalizedServiceEnvironmentVariables.length)
|
|
465
584
|
) {
|
|
466
585
|
throw new Error(
|
|
467
586
|
"config.supabase.serviceEnvironmentFile and serviceEnvironmentVariables must be configured together.",
|
|
468
587
|
);
|
|
469
588
|
}
|
|
470
|
-
const authenticationProviders =
|
|
471
|
-
?
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
589
|
+
const authenticationProviders = normalizedAuthentication
|
|
590
|
+
? normalizedAuthentication.providers.map(({ name }) => name)
|
|
591
|
+
: safety.authenticationProviders
|
|
592
|
+
? assertStringArray(
|
|
593
|
+
safety.authenticationProviders,
|
|
594
|
+
"config.safety.authenticationProviders",
|
|
595
|
+
)
|
|
596
|
+
: [];
|
|
476
597
|
if (runtimeTarget === "postgresql" && authenticationProviders.length > 0) {
|
|
477
598
|
throw new Error(
|
|
478
599
|
"config.safety.authenticationProviders is available only for the Supabase target.",
|
|
@@ -500,12 +621,15 @@ const normalizeConfig = (input) => {
|
|
|
500
621
|
supabase.runtimeWorkdir,
|
|
501
622
|
"config.supabase.runtimeWorkdir",
|
|
502
623
|
),
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
624
|
+
authentication: normalizedAuthentication,
|
|
625
|
+
serviceEnvironmentFile: normalizedAuthentication
|
|
626
|
+
? normalizedAuthentication.environmentFile
|
|
627
|
+
: supabase.serviceEnvironmentFile
|
|
628
|
+
? assertRelativePath(
|
|
629
|
+
supabase.serviceEnvironmentFile,
|
|
630
|
+
"config.supabase.serviceEnvironmentFile",
|
|
631
|
+
)
|
|
632
|
+
: null,
|
|
509
633
|
serviceEnvironmentVariables: Object.freeze(
|
|
510
634
|
normalizedServiceEnvironmentVariables,
|
|
511
635
|
),
|
|
@@ -1005,9 +1129,21 @@ export default defineRehearsalConfig({
|
|
|
1005
1129
|
rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
|
|
1006
1130
|
runtimeWorkdir: ".rehearsal/runtime",
|
|
1007
1131
|
|
|
1008
|
-
// Optional local
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1132
|
+
// Optional local OAuth connection. Secrets stay in the ignored owner-only file.
|
|
1133
|
+
// Register http://127.0.0.1:${ports.api}/auth/v1/callback with the provider.
|
|
1134
|
+
// authentication: {
|
|
1135
|
+
// enableLocalSignup: true,
|
|
1136
|
+
// environmentFile: ".env.rehearsal-service.local",
|
|
1137
|
+
// providers: [
|
|
1138
|
+
// {
|
|
1139
|
+
// name: "google",
|
|
1140
|
+
// clientIdEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_ID",
|
|
1141
|
+
// clientSecretEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_SECRET",
|
|
1142
|
+
// skipNonceCheck: false,
|
|
1143
|
+
// emailOptional: false,
|
|
1144
|
+
// },
|
|
1145
|
+
// ],
|
|
1146
|
+
// },
|
|
1011
1147
|
},
|
|
1012
1148
|
|
|
1013
1149
|
// Immutable local baseline files. Rehearsal never reads production on your behalf.
|
package/src/project/setup.mjs
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
renderDetectedPostgresqlConfig,
|
|
24
24
|
} from "./configuration.mjs";
|
|
25
25
|
import { resolveRuntimeTarget } from "../targets/target.mjs";
|
|
26
|
+
export {
|
|
27
|
+
assertSupportedRehearsalNodeRuntime,
|
|
28
|
+
inspectRehearsalNodeRuntime,
|
|
29
|
+
} from "../shared/node_runtime.mjs";
|
|
30
|
+
import { assertSupportedRehearsalNodeRuntime } from "../shared/node_runtime.mjs";
|
|
26
31
|
|
|
27
32
|
const CONFIG_PATH = "rehearsal.config.mjs";
|
|
28
33
|
const LOCAL_SUPABASE_CONFIG_PATH =
|
|
@@ -82,29 +87,6 @@ export const isRehearsalPortAvailable = async (port) => {
|
|
|
82
87
|
return canBindToPort(port);
|
|
83
88
|
};
|
|
84
89
|
|
|
85
|
-
export const inspectRehearsalNodeRuntime = (
|
|
86
|
-
version = process.versions.node,
|
|
87
|
-
) => {
|
|
88
|
-
const major = Number.parseInt(String(version).split(".")[0], 10);
|
|
89
|
-
return {
|
|
90
|
-
version: String(version),
|
|
91
|
-
major,
|
|
92
|
-
supported: major === 24,
|
|
93
|
-
};
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export const assertSupportedRehearsalNodeRuntime = (
|
|
97
|
-
version = process.versions.node,
|
|
98
|
-
) => {
|
|
99
|
-
const runtime = inspectRehearsalNodeRuntime(version);
|
|
100
|
-
if (!runtime.supported) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`Rehearsal setup requires Node.js 24; this shell is using Node.js ${runtime.version}. Switch to Node.js 24, then rerun setup. With nvm: nvm install 24 && nvm use 24.`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
return runtime;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
90
|
export const findAvailableRehearsalPorts = async ({
|
|
109
91
|
isAvailable = isRehearsalPortAvailable,
|
|
110
92
|
} = {}) => {
|