@rehearsal-db/core 0.1.0-beta.11 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## [0.1.0-beta.12] - 2026-10-04
9
+
10
+ ### Added
11
+
12
+ - Supabase projects can declaratively configure local Google or GitHub OAuth in
13
+ `rehearsal.config.mjs`. Rehearsal derives the credential allowlist, injects only the
14
+ ignored owner-only environment values, and generates the exact loopback callback in
15
+ the disposable runtime without tracking secrets. Local signup requires an explicit
16
+ acknowledgment and is enabled only in that generated runtime copy.
17
+ - Branded, responsive documentation is now hosted on GitHub Pages with searchable
18
+ navigation, light and dark themes, and automatic deployment from `main`.
19
+ - The documentation home page and packaged README show the live monthly npm download
20
+ count and link directly to the npm package.
21
+ - Packed-package onboarding acceptance now proves both fresh root-config creation and a
22
+ real beta.11 upgrade that preserves the user's existing configuration byte for byte.
23
+
24
+ ### Changed
25
+
26
+ - The packaged README now links prominently to the hosted documentation, npm package,
27
+ and issue tracker. Package homepage metadata also points to the hosted documentation.
28
+ - GitHub Pages publishing uses the current Node.js 24 action generations without legacy
29
+ runtime warnings.
30
+ - `rehearsal init` now calls out important optional settings that are available but not
31
+ enabled before showing the current installed-release template.
32
+
33
+ ### Fixed
34
+
35
+ - Existing Google or GitHub provider sections can transition to declarative ownership
36
+ without editing the tracked Supabase template. Rehearsal replaces only a compatible
37
+ section in the disposable runtime copy, while `doctor` refuses ambiguous or
38
+ safety-weakening provider settings before reset.
39
+ - Multi-target plain-text plans now print each declarative OAuth callback, and `init`
40
+ advertises the declarative replacement even when a project still uses legacy provider
41
+ fields.
42
+
8
43
  ## [0.1.0-beta.11] - 2026-10-03
9
44
 
10
45
  ### Added
@@ -314,7 +349,8 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
314
349
  publication uses short-lived trusted OIDC, and every release tag must already exist on
315
350
  protected `main`.
316
351
 
317
- [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.11...HEAD
352
+ [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.12...HEAD
353
+ [0.1.0-beta.12]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.11...v0.1.0-beta.12
318
354
  [0.1.0-beta.11]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.10...v0.1.0-beta.11
319
355
  [0.1.0-beta.10]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.9...v0.1.0-beta.10
320
356
  [0.1.0-beta.9]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.8...v0.1.0-beta.9
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Rehearsal
2
2
 
3
+ [![Monthly npm downloads](https://img.shields.io/npm/dm/%40rehearsal-db%2Fcore?style=flat-square&logo=npm&logoColor=white&label=npm%20downloads&labelColor=1c2a34&color=287b5c)](https://www.npmjs.com/package/@rehearsal-db/core)
4
+
5
+ [Read the documentation](https://ddupasquier.github.io/rehearsal-db/) · [View on npm](https://www.npmjs.com/package/@rehearsal-db/core) · [Report a problem](https://github.com/Ddupasquier/rehearsal-db/issues)
6
+
3
7
  Rehearsal tests PostgreSQL and Supabase migrations on your computer before you run them
4
8
  anywhere important. It restores safe test data into a disposable local database, applies
5
9
  only the migrations you approve, and runs your project's own test command.
@@ -55,6 +59,11 @@ docker pull postgres:17-alpine
55
59
 
56
60
  Rehearsal itself never downloads a database image during a rehearsal.
57
61
 
62
+ For local Google or GitHub sign-in, the generated config includes a commented
63
+ `supabase.authentication` example. It names an ignored owner-only credential file;
64
+ Rehearsal generates the provider block and local callback inside the disposable runtime.
65
+ See [Supabase authentication](docs/configuration.md#supabase-authentication).
66
+
58
67
  ## Three terms you will see
59
68
 
60
69
  - **Baseline:** a locked, safe starting copy of your schema and test data.
package/docs/commands.md CHANGED
@@ -34,8 +34,9 @@ Storage files. `baseline create` and `baseline prepare` are local-only.
34
34
 
35
35
  `setup --write` is idempotent: supported existing config files and completed runtime
36
36
  files remain unchanged, while missing safe ignore entries may be appended. On upgrades,
37
- `init` prints the installed release's template for comparison; `init --write` never
38
- replaces an existing config.
37
+ `init` first lists important optional settings that are available but not enabled, then
38
+ prints the installed release's template for comparison. `init --write` never replaces an
39
+ existing config.
39
40
 
40
41
  ## Optional source preparation
41
42
 
@@ -42,9 +42,21 @@ export default defineRehearsalConfig({
42
42
  rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
43
43
  runtimeWorkdir: ".rehearsal/runtime",
44
44
 
45
- // Optional; configure both keys together.
46
- // serviceEnvironmentFile: ".env.rehearsal-service.local",
47
- // serviceEnvironmentVariables: ["LOCAL_IDP_CLIENT_ID", "LOCAL_IDP_SECRET"],
45
+ // Optional local OAuth connection. Secrets stay in the ignored owner-only file.
46
+ // Register http://127.0.0.1:58321/auth/v1/callback with the provider.
47
+ // authentication: {
48
+ // enableLocalSignup: true,
49
+ // environmentFile: ".env.rehearsal-service.local",
50
+ // providers: [
51
+ // {
52
+ // name: "google",
53
+ // clientIdEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_ID",
54
+ // clientSecretEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_SECRET",
55
+ // skipNonceCheck: false,
56
+ // emailOptional: false,
57
+ // },
58
+ // ],
59
+ // },
48
60
  },
49
61
  baseline: {
50
62
  artifactDirectory: ".rehearsal",
@@ -124,7 +136,8 @@ safe, run setup again, and compare the two files before deleting either one.
124
136
  After upgrading, run `npx rehearsal init` to print the current release's generated
125
137
  template beside the path of your existing config. This comparison is read-only; adding a
126
138
  new optional key remains your decision. `npx rehearsal init --write` still refuses to
127
- replace an existing config.
139
+ replace an existing config. Rehearsal lists important optional settings that are
140
+ available but not enabled before printing the template, so they are easier to find.
128
141
 
129
142
  ### Workspaces and custom paths
130
143
 
@@ -247,19 +260,72 @@ negative controls, such as unauthorized, malformed, withheld, or missing records
247
260
  response that merely avoids a server error does not prove that eligible data was
248
261
  published or can be found.
249
262
 
250
- ## Supabase service environment
263
+ ## Supabase authentication
251
264
 
252
- Some local identity providers need a client ID and secret. Configure both fields or
253
- neither:
265
+ Rehearsal can configure a Google or GitHub OAuth connection in its disposable local
266
+ Supabase runtime. Declare the provider and the names of its credential variables:
254
267
 
255
268
  ```ts
256
- serviceEnvironmentFile: ".env.rehearsal-service.local",
257
- serviceEnvironmentVariables: ["LOCAL_IDP_CLIENT_ID", "LOCAL_IDP_SECRET"],
269
+ authentication: {
270
+ enableLocalSignup: true,
271
+ environmentFile: ".env.rehearsal-service.local",
272
+ providers: [
273
+ {
274
+ name: "google",
275
+ clientIdEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_ID",
276
+ clientSecretEnvironmentVariable: "REHEARSAL_GOOGLE_CLIENT_SECRET",
277
+ skipNonceCheck: false,
278
+ emailOptional: false,
279
+ },
280
+ ],
281
+ },
282
+ ```
283
+
284
+ Create the ignored file, add the values, then restrict it to your account:
285
+
286
+ ```dotenv
287
+ REHEARSAL_GOOGLE_CLIENT_ID=your-local-oauth-client-id
288
+ REHEARSAL_GOOGLE_CLIENT_SECRET=your-local-oauth-client-secret
289
+ ```
290
+
291
+ ```bash
292
+ chmod 600 .env.rehearsal-service.local
258
293
  ```
259
294
 
260
- The file must be ignored, owner-readable only, and contain only the exact allowlisted
261
- names. These credentials may authorize an identity handshake, but the callback must
262
- terminate at local Auth. They do not grant hosted database access.
295
+ Register `http://127.0.0.1:<apiPort>/auth/v1/callback` with the provider, using the
296
+ `runtime.apiPort` value from this config. `rehearsal explain` also prints the exact URL.
297
+ Rehearsal writes that callback and `env(...)` references into the disposable runtime; it
298
+ never copies credential values into tracked configuration. Use `name: "github"` and
299
+ distinct GitHub variable names for GitHub. Duplicate providers, reused variable names,
300
+ unsupported providers, extra keys, and a template that already owns the same provider
301
+ are refused. This follows Supabase's documented
302
+ [`env()` configuration pattern](https://supabase.com/docs/guides/local-development/managing-config).
303
+
304
+ `enableLocalSignup: true` explicitly acknowledges that disposable local Auth may create
305
+ the temporary user returned by OAuth. Rehearsal changes the generated runtime copy only;
306
+ the tracked Supabase template and every hosted project remain unchanged.
307
+
308
+ The older `serviceEnvironmentFile`, `serviceEnvironmentVariables`, and
309
+ `safety.authenticationProviders` fields remain compatible for projects that manually
310
+ own their Supabase provider TOML. Do not combine those fields with the declarative
311
+ `authentication` block.
312
+
313
+ To migrate a manual Google or GitHub setup, replace those three legacy config fields
314
+ with the `authentication` block together. The tracked Supabase template may keep its
315
+ existing provider section: Rehearsal replaces a compatible legacy section only in the
316
+ generated disposable runtime copy. The section may contain `enabled`, `client_id`,
317
+ `secret`, `redirect_uri`, `skip_nonce_check`, and `email_optional`. The latter two must
318
+ match the explicit `skipNonceCheck` and `emailOptional` declarations, which default to
319
+ `false`. Keep them false unless the provider integration has a reviewed need; Supabase
320
+ warns in its [CLI configuration reference](https://supabase.com/docs/guides/local-development/cli/config#auth.external.provider.skip_nonce_check)
321
+ that skipping nonce validation reduces replay protection. Unknown settings,
322
+ duplicate sections, nested provider tables, or mismatched options fail
323
+ `rehearsal doctor` before reset. Rehearsal never edits the tracked template during this
324
+ transition.
325
+
326
+ Provider connection and copied-account association are separate. This block lets the
327
+ ordinary application sign-in reach local Auth. An optional `identityPolicy` decides
328
+ whether that verified local identity may claim a reviewed copied account.
263
329
 
264
330
  ## Safety fields
265
331
 
@@ -55,6 +55,13 @@ Supabase or PostgreSQL, and approve the same safe setup preview.
55
55
  The proof command should check behavior changed by the migration. A test that only checks
56
56
  whether the home page loads is usually too weak.
57
57
 
58
+ If the sandbox needs Google or GitHub sign-in, uncomment
59
+ `supabase.authentication` in `rehearsal.config.mjs`. Add the named credentials to
60
+ `.env.rehearsal-service.local`, run `chmod 600 .env.rehearsal-service.local`, and
61
+ register the local callback printed in the config. See
62
+ [Supabase authentication](configuration.md#supabase-authentication). Rehearsal keeps the
63
+ values out of tracked files and generates the disposable provider configuration for you.
64
+
58
65
  ## 3. Prepare safe baseline inputs
59
66
 
60
67
  A **baseline** is the locked starting point restored before each rehearsal. Start with a
@@ -168,7 +175,9 @@ complete reference and [Troubleshooting](troubleshooting.md) when a check fails.
168
175
 
169
176
  Running setup again is safe: existing configuration and runtime files are reported as
170
177
  unchanged. Rehearsal only adds missing ignore entries. It never refreshes an existing
171
- config during an upgrade.
178
+ config during an upgrade. After upgrading, run `npx rehearsal init` to see important
179
+ optional settings you have not enabled and compare your file with the installed
180
+ release's current template.
172
181
 
173
182
  ## Files Rehearsal creates
174
183
 
@@ -157,8 +157,10 @@ graph without an application callback patch:
157
157
 
158
158
  Authentication has three separate parts:
159
159
 
160
- 1. The local Supabase configuration enables Google, GitHub, or email sign-in and reads
161
- any provider credentials from the environment.
160
+ 1. `config.supabase.authentication` can enable a Google or GitHub connection in local
161
+ Supabase and reads its credentials from the ignored owner-only environment file.
162
+ Existing projects may still own that local provider TOML manually. Email sign-in does
163
+ not use an OAuth credential declaration.
162
164
  2. The application completes the ordinary sign-in and receives a normal Supabase
163
165
  session. Rehearsal does not intercept the callback.
164
166
  3. The identity policy matches that verified local identity and transfers only the
@@ -146,9 +146,20 @@ changes.
146
146
 
147
147
  ## Local authentication fails
148
148
 
149
- Confirm the provider is enabled in the dedicated local Supabase config, the allowlisted
150
- service environment file exists with owner-only permissions, and the provider callback
151
- is the local Auth callback. Do not paste provider secrets into config or terminal output.
149
+ For Google or GitHub, confirm `supabase.authentication` is declared in
150
+ `rehearsal.config.mjs`, its ignored environment file exists with `chmod 600`, and the
151
+ provider has the printed local Auth callback registered. Rehearsal generates the
152
+ disposable Supabase provider block.
153
+
154
+ When migrating from the legacy service environment fields, replace those fields together
155
+ with `supabase.authentication` and rerun `rehearsal doctor`. A compatible Google or
156
+ GitHub provider section may remain in the tracked template: Rehearsal supersedes it only
157
+ inside the generated runtime copy. Doctor refuses unknown provider settings, duplicate
158
+ or nested provider sections, and nonce or optional-email settings that do not match the
159
+ new declaration. Keep both options false unless there is a reviewed provider-specific
160
+ need; skipping nonce validation reduces replay protection. Otherwise keep using the
161
+ documented legacy configuration.
162
+ Never paste provider secrets into tracked config or terminal output.
152
163
 
153
164
  If `identity plan` refuses the matcher, read the message literally:
154
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-beta.11",
3
+ "version": "0.1.0-beta.12",
4
4
  "private": false,
5
5
  "description": "Safely rehearse PostgreSQL and Supabase migrations against sanitized, production-shaped data.",
6
6
  "repository": {
@@ -10,7 +10,7 @@
10
10
  "bugs": {
11
11
  "url": "https://github.com/Ddupasquier/rehearsal-db/issues"
12
12
  },
13
- "homepage": "https://github.com/Ddupasquier/rehearsal-db#readme",
13
+ "homepage": "https://ddupasquier.github.io/rehearsal-db/",
14
14
  "publishConfig": {
15
15
  "access": "public",
16
16
  "tag": "beta",
@@ -62,6 +62,7 @@
62
62
  "test:fixture": "node scripts/verification/fixtures/supabase.mjs",
63
63
  "test:fixture:postgresql": "node scripts/verification/fixtures/postgresql.mjs",
64
64
  "test:fixture:dependent": "node scripts/verification/fixtures/dependent.mjs",
65
+ "test:fixture:onboarding": "node scripts/verification/fixtures/onboarding.mjs",
65
66
  "test:fixture:standalone": "node scripts/verification/fixtures/standalone.mjs",
66
67
  "test:fixture:privacy": "node scripts/verification/fixtures/privacy.mjs",
67
68
  "check": "npm run check:syntax && npm run test && npm run docs:build && npm run format:check && npm run package:audit",
@@ -49,11 +49,27 @@ export const runRehearsalInit = async ({ projectRoot, flags }) => {
49
49
  applicationUrl: loaded.config.runtime.applicationUrl,
50
50
  databasePort: loaded.config.runtime.ports.database,
51
51
  });
52
+ const usesLegacyAuthentication = Boolean(
53
+ loaded.config.supabase?.serviceEnvironmentFile ||
54
+ loaded.config.safety.authenticationProviders.length,
55
+ );
56
+ const availableOptions =
57
+ loaded.config.supabase && !loaded.config.supabase.authentication
58
+ ? [
59
+ {
60
+ path: "supabase.authentication",
61
+ summary: usesLegacyAuthentication
62
+ ? "A declarative Google/GitHub replacement is available; replace the legacy provider fields together after review."
63
+ : "Configure local Google or GitHub sign-in with credentials kept outside tracked files.",
64
+ },
65
+ ]
66
+ : [];
52
67
  return {
53
68
  mode: "current-template",
54
69
  destination: relative(projectRoot, existingPath),
55
70
  detected,
56
71
  source,
72
+ availableOptions,
57
73
  nextAction:
58
74
  "Your existing configuration was not changed. Compare it with this installed-release template to discover new optional keys.",
59
75
  };
@@ -70,6 +86,7 @@ export const runRehearsalInit = async ({ projectRoot, flags }) => {
70
86
  destination: relative(projectRoot, destination),
71
87
  detected,
72
88
  source,
89
+ availableOptions: [],
73
90
  nextAction: flags.write
74
91
  ? "Review the generated safety settings and run rehearsal doctor."
75
92
  : "Review this preview, then rerun rehearsal init --write to create it.",
@@ -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");
@@ -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.
@@ -11,6 +11,10 @@ import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { performance } from "node:perf_hooks";
13
13
  import { verifyActiveBaseline } from "../baseline/artifact.mjs";
14
+ import {
15
+ applySupabaseAuthenticationProviders,
16
+ inspectSupabaseAuthenticationProviderTransition,
17
+ } from "../identity/provider_configuration.mjs";
14
18
  import {
15
19
  inspectDetectedProject,
16
20
  loadRehearsalConfig,
@@ -233,6 +237,9 @@ export const buildRehearsalPlan = async (options = {}) => {
233
237
  hostedAccess: config.safety.hostedAccess,
234
238
  outboundNetwork: config.safety.outboundNetwork,
235
239
  authenticationProviders: config.safety.authenticationProviders,
240
+ authenticationCallback: config.supabase?.authentication
241
+ ? `http://127.0.0.1:${config.runtime.ports.api}/auth/v1/callback`
242
+ : null,
236
243
  barriers: [
237
244
  "versioned configuration accepts loopback hosts only",
238
245
  `runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
@@ -459,6 +466,32 @@ export const runRehearsalDoctor = async (options = {}) => {
459
466
  remediation:
460
467
  "Correct the missing project path in the Rehearsal configuration.",
461
468
  },
469
+ ...(config.supabase?.authentication
470
+ ? [
471
+ {
472
+ id: "authentication-runtime-config",
473
+ label: "Local authentication configuration",
474
+ run: async () => {
475
+ const source = await readFile(paths.rehearsalConfig, "utf8");
476
+ const transition =
477
+ inspectSupabaseAuthenticationProviderTransition({
478
+ source,
479
+ authentication: config.supabase.authentication,
480
+ });
481
+ applySupabaseAuthenticationProviders({
482
+ source,
483
+ authentication: config.supabase.authentication,
484
+ apiPort: config.runtime.ports.api,
485
+ });
486
+ return transition.replacedProviders.length
487
+ ? `compatible legacy ${transition.replacedProviders.join(", ")} provider configuration will be replaced only in the disposable runtime copy`
488
+ : "declarative provider configuration can be generated without changing the tracked template";
489
+ },
490
+ remediation:
491
+ "Review the named provider section and make skipNonceCheck or emailOptional match explicitly in config.supabase.authentication; otherwise keep the documented manual provider configuration.",
492
+ },
493
+ ]
494
+ : []),
462
495
  {
463
496
  id: "sanitization-policy",
464
497
  label: "Reviewed sanitization policy",
@@ -44,6 +44,7 @@ import {
44
44
  } from "../runtime/migration_history.mjs";
45
45
  import { loadRehearsalConfig } from "../project/configuration.mjs";
46
46
  import { readRehearsalServiceEnvironment } from "../runtime/service_environment.mjs";
47
+ import { applySupabaseAuthenticationProviders } from "../identity/provider_configuration.mjs";
47
48
  import { readBoundRuntimeSanitizationPolicy } from "../baseline/sanitization_policy.mjs";
48
49
  import {
49
50
  captureLocalPublicSchema,
@@ -315,7 +316,16 @@ const prepareRuntimeWorkdir = async ({ baseline, paths }) => {
315
316
  recursive: true,
316
317
  mode: 0o700,
317
318
  });
318
- await cp(configTemplatePath, join(runtimeSupabaseDirectory, "config.toml"));
319
+ const configTemplate = await readFile(configTemplatePath, "utf8");
320
+ await writeFile(
321
+ join(runtimeSupabaseDirectory, "config.toml"),
322
+ applySupabaseAuthenticationProviders({
323
+ source: configTemplate,
324
+ authentication: config.supabase.authentication,
325
+ apiPort: config.runtime.ports.api,
326
+ }),
327
+ { mode: 0o600 },
328
+ );
319
329
  for (const filename of Object.keys(baseline.migrations).sort()) {
320
330
  await cp(
321
331
  join(paths.migrationsDirectory, filename),