@rehearsal-db/core 0.1.0-beta.10 → 0.1.0-beta.12

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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createHash } from "node:crypto";
9
- import { readFile, writeFile } from "node:fs/promises";
9
+ import { readFile } from "node:fs/promises";
10
10
  import { join, relative } from "node:path";
11
11
  import { performance } from "node:perf_hooks";
12
12
  import { parseEnv } from "node:util";
@@ -18,12 +18,7 @@ import {
18
18
  renderHumanError,
19
19
  serializeRehearsalError,
20
20
  } from "../shared/diagnostics.mjs";
21
- import {
22
- inspectDetectedProject,
23
- findRehearsalConfigPath,
24
- loadRehearsalConfig,
25
- renderDetectedConfig,
26
- } from "../project/configuration.mjs";
21
+ import { loadRehearsalConfig } from "../project/configuration.mjs";
27
22
  import {
28
23
  inspectRehearsalBaseline,
29
24
  inspectRehearsalMigrations,
@@ -113,6 +108,7 @@ import {
113
108
  renderSourceRetirement,
114
109
  renderSupportReport,
115
110
  } from "./renderers.mjs";
111
+ import { runRehearsalInit } from "./init_command.mjs";
116
112
 
117
113
  const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
118
114
  const projectRoot = process.cwd();
@@ -148,54 +144,13 @@ const emit = ({ command, data, flags, render, status = "success" }) => {
148
144
  else console.log(render(data, flags.verbosity));
149
145
  };
150
146
 
151
- const runInit = async ({ flags }) => {
152
- const detected = await inspectDetectedProject({ projectRoot });
153
- const source = renderDetectedConfig(detected);
154
- const destination = join(projectRoot, "rehearsal.config.mjs");
155
- let existingPath = null;
156
- try {
157
- existingPath = await findRehearsalConfigPath({ projectRoot });
158
- } catch (error) {
159
- if (
160
- !String(error?.message ?? error).startsWith("No Rehearsal configuration")
161
- ) {
162
- throw error;
163
- }
164
- }
165
- if (existingPath) {
166
- if (flags.write) {
167
- throw new Error(
168
- `A Rehearsal configuration already exists at ${relative(projectRoot, existingPath)}; Rehearsal will not overwrite it.`,
169
- );
170
- }
171
- return {
172
- mode: "existing",
173
- destination: relative(projectRoot, existingPath),
174
- detected,
175
- source: "",
176
- nextAction:
177
- "Review the existing configuration, then run rehearsal doctor.",
178
- };
179
- }
180
- if (flags.write)
181
- await writeFile(destination, source, { flag: "wx", mode: 0o600 });
182
- return {
183
- mode: flags.write ? "written" : "preview",
184
- destination: relative(projectRoot, destination),
185
- detected,
186
- source,
187
- nextAction: flags.write
188
- ? "Review the generated safety settings and run rehearsal doctor."
189
- : "Review this preview, then rerun rehearsal init --write to create it.",
190
- };
191
- };
192
-
193
147
  const runSetup = async ({ flags, planOptions }) => {
194
148
  const plan =
195
149
  flags.setupPlan ??
196
150
  (await planRehearsalSetup({
197
151
  projectRoot,
198
- target: flags.target ?? "supabase",
152
+ configPath: planOptions.configPath,
153
+ target: flags.target,
199
154
  }));
200
155
  if (flags.write) await applyRehearsalSetup(plan);
201
156
  const result = summarizeRehearsalSetup(plan, {
@@ -417,7 +372,7 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
417
372
  return;
418
373
  }
419
374
  if (command === "init") {
420
- const data = await runInit({ flags });
375
+ const data = await runRehearsalInit({ projectRoot, flags });
421
376
  emit({ command, data, flags, render: renderInit });
422
377
  return;
423
378
  }
@@ -14,6 +14,16 @@ export const renderPlan = (plan, verbosity) => {
14
14
  ...plan.targets.flatMap((target) => [
15
15
  `${target.name} — ${target.plan.config.project}`,
16
16
  ` ✓ ${target.plan.environment.kind}`,
17
+ ...(target.plan.environment.authenticationProviders.length
18
+ ? [
19
+ ` ✓ External identity providers ${target.plan.environment.authenticationProviders.join(", ")}`,
20
+ ...(target.plan.environment.authenticationCallback
21
+ ? [
22
+ ` → Register callback ${target.plan.environment.authenticationCallback}`,
23
+ ]
24
+ : []),
25
+ ]
26
+ : []),
17
27
  ` ✓ baseline ${target.plan.baseline.generationId}`,
18
28
  ` ✓ ${formatCount(target.plan.baseline.tableCount, "table")}; ${formatCount(target.plan.baseline.rowCount, "row")}`,
19
29
  ...(target.plan.migrations.candidates.length
@@ -69,6 +79,11 @@ export const renderPlan = (plan, verbosity) => {
69
79
  ...(plan.environment.authenticationProviders.length
70
80
  ? [
71
81
  ` ✓ External identity providers ${plan.environment.authenticationProviders.join(", ")}`,
82
+ ...(plan.environment.authenticationCallback
83
+ ? [
84
+ ` → Register callback ${plan.environment.authenticationCallback}`,
85
+ ]
86
+ : []),
72
87
  ]
73
88
  : []),
74
89
  "",
@@ -194,6 +209,15 @@ export const renderInit = (result) =>
194
209
  `Detected package manager: ${result.detected.packageManager}`,
195
210
  `Detected Supabase config: ${result.detected.hasSupabaseConfig ? "yes" : "no"}`,
196
211
  `Detected migrations: ${result.detected.hasMigrations ? "yes" : "no"}`,
212
+ ...(result.availableOptions?.length
213
+ ? [
214
+ "",
215
+ "AVAILABLE BUT NOT ENABLED",
216
+ ...result.availableOptions.map(
217
+ (option) => ` → ${option.path}: ${option.summary}`,
218
+ ),
219
+ ]
220
+ : []),
197
221
  ...(result.source ? ["", result.source] : []),
198
222
  result.nextAction,
199
223
  ].join("\n");
@@ -566,8 +590,13 @@ export const renderIdentityPlan = ({ mode, plan }) =>
566
590
  [
567
591
  `LOCAL IDENTITY — ${mode.toUpperCase()}`,
568
592
  `Identity: ${plan.review.name}`,
569
- `Provider: ${plan.review.provider}`,
570
- `Approved email receipt: ${plan.review.approvedEmailSha256}`,
593
+ `Matcher: ${plan.review.matcher.type}`,
594
+ plan.review.matcher.type === "verified-email"
595
+ ? `Providers: ${plan.review.matcher.providers.join(", ")}`
596
+ : `Provider: ${plan.review.matcher.provider}`,
597
+ plan.review.matcher.type === "verified-email"
598
+ ? `Approved email receipt: ${plan.review.matcher.approvedEmailSha256}`
599
+ : `Approved subject receipt: ${plan.review.matcher.approvedSubjectSha256}`,
571
600
  `Relational references: ${plan.review.references.length}`,
572
601
  `Nested JSON references: ${plan.review.jsonReferences.length}`,
573
602
  `Signup defaults: ${plan.review.signupDefaults.length}`,
@@ -12,6 +12,12 @@ const UUID =
12
12
  /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
13
13
  const SHA256 = /^[a-f0-9]{64}$/u;
14
14
  const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
15
+ const SUPPORTED_IDENTITY_PROVIDERS = Object.freeze([
16
+ "email",
17
+ "github",
18
+ "google",
19
+ ]);
20
+ const identityPlanValues = new WeakMap();
15
21
 
16
22
  const isObject = (value) =>
17
23
  value !== null && typeof value === "object" && !Array.isArray(value);
@@ -78,6 +84,114 @@ const defaultMatcher = (value, label) => {
78
84
  return Object.freeze({ kind: "exact", value: jsonValue(value, label) });
79
85
  };
80
86
 
87
+ const provider = (value, label, { subject = false } = {}) => {
88
+ if (!SUPPORTED_IDENTITY_PROVIDERS.includes(value)) {
89
+ throw new Error(`${label} is unsupported.`);
90
+ }
91
+ if (subject && value === "email") {
92
+ throw new Error(
93
+ `${label} cannot use email with a provider-subject matcher.`,
94
+ );
95
+ }
96
+ return value;
97
+ };
98
+
99
+ const normalizeIdentityMatcher = (entry, label) => {
100
+ const legacyKeys = [
101
+ "provider",
102
+ "emailEnvironmentVariable",
103
+ "approvedEmailSha256",
104
+ ];
105
+ const hasLegacyMatcher = legacyKeys.some((key) => entry[key] !== undefined);
106
+ if (entry.matcher !== undefined && hasLegacyMatcher) {
107
+ throw new Error(
108
+ `${label} cannot combine matcher with legacy provider and email fields.`,
109
+ );
110
+ }
111
+ if (entry.matcher === undefined) {
112
+ const legacyProvider = provider(entry.provider, `${label}.provider`);
113
+ if (!ENVIRONMENT_KEY.test(entry.emailEnvironmentVariable ?? "")) {
114
+ throw new Error(`${label}.emailEnvironmentVariable is invalid.`);
115
+ }
116
+ if (!SHA256.test(entry.approvedEmailSha256 ?? "")) {
117
+ throw new Error(`${label}.approvedEmailSha256 must be a SHA-256.`);
118
+ }
119
+ return Object.freeze({
120
+ type: "verified-email",
121
+ providers: Object.freeze([legacyProvider]),
122
+ emailEnvironmentVariable: entry.emailEnvironmentVariable,
123
+ approvedEmailSha256: entry.approvedEmailSha256,
124
+ });
125
+ }
126
+ if (!isObject(entry.matcher)) {
127
+ throw new Error(`${label}.matcher must be an object.`);
128
+ }
129
+ const matcherLabel = `${label}.matcher`;
130
+ if (entry.matcher.type === "verified-email") {
131
+ keys(
132
+ entry.matcher,
133
+ ["type", "providers", "emailEnvironmentVariable", "approvedEmailSha256"],
134
+ matcherLabel,
135
+ );
136
+ if (
137
+ !Array.isArray(entry.matcher.providers) ||
138
+ entry.matcher.providers.length === 0
139
+ ) {
140
+ throw new Error(`${matcherLabel}.providers must not be empty.`);
141
+ }
142
+ const providers = entry.matcher.providers.map((value, index) =>
143
+ provider(value, `${matcherLabel}.providers[${index}]`),
144
+ );
145
+ if (new Set(providers).size !== providers.length) {
146
+ throw new Error(`${matcherLabel}.providers must not contain duplicates.`);
147
+ }
148
+ if (!ENVIRONMENT_KEY.test(entry.matcher.emailEnvironmentVariable ?? "")) {
149
+ throw new Error(`${matcherLabel}.emailEnvironmentVariable is invalid.`);
150
+ }
151
+ if (!SHA256.test(entry.matcher.approvedEmailSha256 ?? "")) {
152
+ throw new Error(`${matcherLabel}.approvedEmailSha256 must be a SHA-256.`);
153
+ }
154
+ return Object.freeze({
155
+ type: "verified-email",
156
+ providers: Object.freeze(providers),
157
+ emailEnvironmentVariable: entry.matcher.emailEnvironmentVariable,
158
+ approvedEmailSha256: entry.matcher.approvedEmailSha256,
159
+ });
160
+ }
161
+ if (entry.matcher.type === "provider-subject") {
162
+ keys(
163
+ entry.matcher,
164
+ [
165
+ "type",
166
+ "provider",
167
+ "subjectEnvironmentVariable",
168
+ "approvedSubjectSha256",
169
+ ],
170
+ matcherLabel,
171
+ );
172
+ const subjectProvider = provider(
173
+ entry.matcher.provider,
174
+ `${matcherLabel}.provider`,
175
+ { subject: true },
176
+ );
177
+ if (!ENVIRONMENT_KEY.test(entry.matcher.subjectEnvironmentVariable ?? "")) {
178
+ throw new Error(`${matcherLabel}.subjectEnvironmentVariable is invalid.`);
179
+ }
180
+ if (!SHA256.test(entry.matcher.approvedSubjectSha256 ?? "")) {
181
+ throw new Error(
182
+ `${matcherLabel}.approvedSubjectSha256 must be a SHA-256.`,
183
+ );
184
+ }
185
+ return Object.freeze({
186
+ type: "provider-subject",
187
+ provider: subjectProvider,
188
+ subjectEnvironmentVariable: entry.matcher.subjectEnvironmentVariable,
189
+ approvedSubjectSha256: entry.matcher.approvedSubjectSha256,
190
+ });
191
+ }
192
+ throw new Error(`${matcherLabel}.type is unsupported.`);
193
+ };
194
+
81
195
  export const validateIdentityPolicy = (policy) => {
82
196
  if (
83
197
  !isObject(policy) ||
@@ -100,6 +214,7 @@ export const validateIdentityPolicy = (policy) => {
100
214
  if (
101
215
  ![
102
216
  "name",
217
+ "matcher",
103
218
  "provider",
104
219
  "emailEnvironmentVariable",
105
220
  "approvedEmailSha256",
@@ -121,14 +236,7 @@ export const validateIdentityPolicy = (policy) => {
121
236
  const name = entry.name;
122
237
  if (names.has(name)) throw new Error(`Identity policy duplicates ${name}.`);
123
238
  names.add(name);
124
- if (!["google", "email"].includes(entry.provider))
125
- throw new Error(`${label}.provider is unsupported.`);
126
- if (!ENVIRONMENT_KEY.test(entry.emailEnvironmentVariable ?? "")) {
127
- throw new Error(`${label}.emailEnvironmentVariable is invalid.`);
128
- }
129
- if (!SHA256.test(entry.approvedEmailSha256 ?? "")) {
130
- throw new Error(`${label}.approvedEmailSha256 must be a SHA-256.`);
131
- }
239
+ const matcher = normalizeIdentityMatcher(entry, label);
132
240
  if (!UUID.test(entry.placeholderUserId ?? ""))
133
241
  throw new Error(`${label}.placeholderUserId is invalid.`);
134
242
  const references = (entry.references ?? []).map(
@@ -341,9 +449,7 @@ export const validateIdentityPolicy = (policy) => {
341
449
  }
342
450
  return Object.freeze({
343
451
  name,
344
- provider: entry.provider,
345
- emailEnvironmentVariable: entry.emailEnvironmentVariable,
346
- approvedEmailSha256: entry.approvedEmailSha256,
452
+ matcher,
347
453
  placeholderUserId: entry.placeholderUserId.toLowerCase(),
348
454
  references: Object.freeze(references),
349
455
  jsonReferences: Object.freeze(jsonReferences),
@@ -368,20 +474,32 @@ export const createIdentityClaimPlan = ({
368
474
  const normalized = validateIdentityPolicy(policy);
369
475
  const identity = normalized.identities.find((entry) => entry.name === name);
370
476
  if (!identity) throw new Error(`Identity policy does not declare ${name}.`);
371
- const email = environment[identity.emailEnvironmentVariable]
372
- ?.trim()
373
- .toLowerCase();
374
- if (!email || hash(email) !== identity.approvedEmailSha256) {
477
+ const matcher = identity.matcher;
478
+ const environmentVariable =
479
+ matcher.type === "verified-email"
480
+ ? matcher.emailEnvironmentVariable
481
+ : matcher.subjectEnvironmentVariable;
482
+ const rawValue = environment[environmentVariable];
483
+ const matchValue =
484
+ matcher.type === "verified-email"
485
+ ? rawValue?.trim().toLowerCase()
486
+ : rawValue?.trim();
487
+ const approvedSha256 =
488
+ matcher.type === "verified-email"
489
+ ? matcher.approvedEmailSha256
490
+ : matcher.approvedSubjectSha256;
491
+ if (!matchValue || hash(matchValue) !== approvedSha256) {
375
492
  throw new Error(
376
- "Local identity email does not match the reviewed receipt.",
493
+ matcher.type === "verified-email"
494
+ ? "Local identity email does not match the reviewed receipt."
495
+ : "Local identity provider subject does not match the reviewed receipt.",
377
496
  );
378
497
  }
379
498
  const review = {
380
- planVersion: 2,
499
+ planVersion: 3,
381
500
  operation: "claim-local-identity",
382
501
  name: identity.name,
383
- provider: identity.provider,
384
- approvedEmailSha256: identity.approvedEmailSha256,
502
+ matcher,
385
503
  placeholderUserId: identity.placeholderUserId,
386
504
  references: identity.references,
387
505
  jsonReferences: identity.jsonReferences,
@@ -404,12 +522,13 @@ export const createIdentityClaimPlan = ({
404
522
  }
405
523
  : null,
406
524
  };
407
- return Object.freeze({
525
+ const plan = {
408
526
  identity,
409
- email,
410
527
  review: Object.freeze(review),
411
528
  digest: hash(canonical(review)),
412
- });
529
+ };
530
+ identityPlanValues.set(plan, matchValue);
531
+ return Object.freeze(plan);
413
532
  };
414
533
 
415
534
  const inspectDeclaredDefault = ({ row, declaration }) => {
@@ -492,6 +611,84 @@ const countIdentityRows = async ({ client, reference, userId }) => {
492
611
  return count;
493
612
  };
494
613
 
614
+ const findVerifiedLocalIdentity = async ({ client, plan, matchValue }) => {
615
+ const matcher = plan.identity.matcher;
616
+ if (matcher.type === "verified-email") {
617
+ const match = await client.query(
618
+ `select u.id::text, i.provider
619
+ from auth.users u
620
+ join auth.identities i on i.user_id = u.id
621
+ where lower(coalesce(i.identity_data->>'email', u.email)) = $1
622
+ and i.provider = any($2::text[])
623
+ and (
624
+ coalesce((i.identity_data->>'email_verified')::boolean, false)
625
+ or u.email_confirmed_at is not null
626
+ )
627
+ for update of u`,
628
+ [matchValue, matcher.providers],
629
+ );
630
+ if (match.rows.length !== 1) {
631
+ throw new Error(
632
+ match.rows.length === 0
633
+ ? "No verified local identity matches the reviewed email and provider allowlist."
634
+ : "More than one verified local identity matches the reviewed email and provider allowlist.",
635
+ );
636
+ }
637
+ const matchedProvider =
638
+ match.rows[0].provider ??
639
+ (matcher.providers.length === 1 ? matcher.providers[0] : null);
640
+ if (!matcher.providers.includes(matchedProvider)) {
641
+ throw new Error("Matched local identity provider is invalid.");
642
+ }
643
+ return { id: match.rows[0].id, provider: matchedProvider };
644
+ }
645
+ const candidates = await client.query(
646
+ `select u.id::text,
647
+ i.provider,
648
+ i.provider_id::text,
649
+ i.identity_data->>'sub' as subject
650
+ from auth.users u
651
+ join auth.identities i on i.user_id = u.id
652
+ where i.provider = $1
653
+ and (
654
+ coalesce((i.identity_data->>'email_verified')::boolean, false)
655
+ or u.email_confirmed_at is not null
656
+ )
657
+ for update of u`,
658
+ [matcher.provider],
659
+ );
660
+ const matches = [];
661
+ for (const candidate of candidates.rows) {
662
+ const subjects = [candidate.provider_id, candidate.subject]
663
+ .filter((value) => typeof value === "string" && value.trim())
664
+ .map((value) => value.trim());
665
+ const uniqueSubjects = [...new Set(subjects)];
666
+ if (
667
+ uniqueSubjects.some(
668
+ (subject) => hash(subject) === matcher.approvedSubjectSha256,
669
+ )
670
+ ) {
671
+ if (uniqueSubjects.length !== 1) {
672
+ throw new Error(
673
+ "The matched local provider identity has conflicting stable subjects.",
674
+ );
675
+ }
676
+ matches.push({ id: candidate.id, provider: candidate.provider });
677
+ }
678
+ }
679
+ if (matches.length !== 1) {
680
+ throw new Error(
681
+ matches.length === 0
682
+ ? "No verified local identity matches the reviewed provider subject."
683
+ : "More than one verified local identity matches the reviewed provider subject.",
684
+ );
685
+ }
686
+ if (matches[0].provider !== matcher.provider) {
687
+ throw new Error("Matched local identity provider is invalid.");
688
+ }
689
+ return matches[0];
690
+ };
691
+
495
692
  export const applyIdentityClaim = async ({
496
693
  plan,
497
694
  confirmation,
@@ -504,6 +701,12 @@ export const applyIdentityClaim = async ({
504
701
  `Identity claim confirmation does not match. Expected ${plan.digest}.`,
505
702
  );
506
703
  }
704
+ const matchValue = identityPlanValues.get(plan);
705
+ if (!matchValue) {
706
+ throw new Error(
707
+ "Identity claim plans must be created from the reviewed local matcher environment.",
708
+ );
709
+ }
507
710
  const target = new URL(connectionString);
508
711
  if (!["127.0.0.1", "::1", "localhost"].includes(target.hostname)) {
509
712
  throw new Error(
@@ -525,27 +728,12 @@ export const applyIdentityClaim = async ({
525
728
  let storageObjectsTransferred = 0;
526
729
  try {
527
730
  await client.query("begin");
528
- const match = await client.query(
529
- `select u.id::text
530
- from auth.users u
531
- join auth.identities i on i.user_id = u.id
532
- where lower(coalesce(i.identity_data->>'email', u.email)) = $1
533
- and i.provider = $2
534
- and (
535
- coalesce((i.identity_data->>'email_verified')::boolean, false)
536
- or u.email_confirmed_at is not null
537
- )
538
- for update of u`,
539
- [plan.email, plan.identity.provider],
540
- );
541
- if (match.rows.length !== 1) {
542
- throw new Error(
543
- match.rows.length === 0
544
- ? "No single verified local provider identity matches the reviewed email."
545
- : "More than one local provider identity matches the reviewed email.",
546
- );
547
- }
548
- const localUserId = match.rows[0].id;
731
+ const localIdentity = await findVerifiedLocalIdentity({
732
+ client,
733
+ plan,
734
+ matchValue,
735
+ });
736
+ const localUserId = localIdentity.id;
549
737
  if (!UUID.test(localUserId))
550
738
  throw new Error("Matched local identity is invalid.");
551
739
  const placeholderUser = await client.query(
@@ -932,7 +1120,7 @@ export const applyIdentityClaim = async ({
932
1120
  }
933
1121
  return {
934
1122
  name: plan.identity.name,
935
- provider: plan.identity.provider,
1123
+ provider: localIdentity.provider,
936
1124
  claimed: true,
937
1125
  removedSignupDefaults,
938
1126
  tokenHookVerified,
@@ -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
+ };