@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +11 -2
  4. package/docs/commands.md +18 -13
  5. package/docs/configuration.md +78 -12
  6. package/docs/getting-started.md +14 -4
  7. package/docs/production-source.md +138 -16
  8. package/docs/runtime-policies.md +4 -2
  9. package/docs/sanitization.md +73 -7
  10. package/docs/security-model.md +8 -0
  11. package/docs/standalone-workflow.md +24 -9
  12. package/docs/troubleshooting.md +26 -8
  13. package/docs/tutorial.md +2 -1
  14. package/package.json +4 -3
  15. package/src/baseline/artifact.mjs +27 -4
  16. package/src/baseline/privacy_engine.mjs +220 -9
  17. package/src/cli/arguments.mjs +3 -0
  18. package/src/cli/guided.mjs +8 -6
  19. package/src/cli/init_command.mjs +17 -0
  20. package/src/cli/rehearsal.mjs +20 -2
  21. package/src/cli/renderers.mjs +41 -4
  22. package/src/cli/runtime_commands.mjs +92 -9
  23. package/src/cli/source_commands.mjs +26 -5
  24. package/src/identity/provider_configuration.mjs +177 -0
  25. package/src/project/configuration.d.mts +11 -0
  26. package/src/project/configuration.mjs +155 -19
  27. package/src/project/setup.mjs +5 -23
  28. package/src/runtime/plan.mjs +40 -3
  29. package/src/runtime/restore.mjs +13 -5
  30. package/src/shared/diagnostics.mjs +7 -0
  31. package/src/shared/node_runtime.mjs +31 -0
  32. package/src/source/access.mjs +224 -49
  33. package/src/source/asset_transfer.mjs +35 -5
  34. package/src/source/baseline.mjs +42 -6
  35. package/src/source/external_reader.mjs +255 -0
  36. package/src/source/postgresql_access.mjs +143 -42
  37. package/src/source/privacy_paths.mjs +38 -0
  38. 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 === "migrate" ||
349
- (action === "run" &&
350
- /migrateRuntime|candidate migration|migration up/iu.test(
351
- completeOutput,
352
- ))
353
- ? "migration_candidate_failure"
354
- : inferred.category;
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 { readPrivacyKey } from "../baseline/privacy_engine.mjs";
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: sourcePolicy.assets,
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
- ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment }) =>
262
+ ({
263
+ runRuntimeStack,
264
+ prepareDependentTargets,
265
+ topologyCommandEnvironment,
266
+ buildPlan = buildRefreshWorkflowPlan,
267
+ refreshBaseline = runSourceBaselineRefresh,
268
+ }) =>
248
269
  async ({ flags, planOptions }) => {
249
- const context = await buildRefreshWorkflowPlan(planOptions);
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 runSourceBaselineRefresh({ planOptions });
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 serviceEnvironmentVariables =
441
- supabase?.serviceEnvironmentVariables ?? [];
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(supabase?.serviceEnvironmentFile) !==
464
- Boolean(normalizedServiceEnvironmentVariables.length)
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 = safety.authenticationProviders
471
- ? assertStringArray(
472
- safety.authenticationProviders,
473
- "config.safety.authenticationProviders",
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
- serviceEnvironmentFile: supabase.serviceEnvironmentFile
504
- ? assertRelativePath(
505
- supabase.serviceEnvironmentFile,
506
- "config.supabase.serviceEnvironmentFile",
507
- )
508
- : null,
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-only identity-provider credentials. Enable both keys together.
1009
- // serviceEnvironmentFile: ".env.rehearsal-service.local",
1010
- // serviceEnvironmentVariables: ["LOCAL_IDP_CLIENT_ID", "LOCAL_IDP_SECRET"],
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.
@@ -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
  } = {}) => {