diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index edf2b3ef1c..ad8cb1feb6 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -8,134 +8,20 @@ "**", "!packages/config/**", "!packages/stack/**", - "!apps/cli/src/auth/**", - "!apps/cli/src/cli/**", - "!apps/cli/src/commands/**", - "!apps/cli/src/config/**", - "!apps/cli/src/docs/**", - "!apps/cli/src/globals.d.ts", - "!apps/cli/src/main.ts", - "!apps/cli/src/output/**", - "!apps/cli/src/shared/**", - "!apps/cli/src/telemetry/**", + "!apps/cli/src/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/cli/bin.ts", "apps/cli/src/shared/compute/stacks/**", "apps/cli/src/shared/feedback/database.types.ts", + "apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts", + "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts", "!apps/cli/tests/helpers/branches-live.ts", "!apps/cli/tests/helpers/compute.ts", "!apps/cli/tests/helpers/migration-live.ts", "!apps/cli/tests/helpers/notebooks.ts", "!apps/cli/tests/helpers/postgres-config-live.ts", "!apps/cli/tests/helpers/secrets-live.ts", - "!apps/cli/tests/helpers/storage-live.ts", - "!apps/cli/src/command-internal/agent-mode.*", - "!apps/cli/src/command-internal/api-*", - "!apps/cli/src/command-internal/bitbucket-pipeline.ts", - "!apps/cli/src/command-internal/branch-ref.*", - "!apps/cli/src/command-internal/branch-target.*", - "!apps/cli/src/command-internal/bundled-postgres-client.*", - "!apps/cli/src/command-internal/colors.*", - "!apps/cli/src/command-internal/config-pull-run.*", - "!apps/cli/src/command-internal/config-validate.*", - "!apps/cli/src/command-internal/connect-errors.*", - "!apps/cli/src/command-internal/container-cli.*", - "!apps/cli/src/command-internal/db-*.ts", - "!apps/cli/src/command-internal/db-bootstrap/**", - "!apps/cli/src/command-internal/debug-logger.*", - "!apps/cli/src/command-internal/detect-content-type.*", - "!apps/cli/src/command-internal/diff-engine.*", - "!apps/cli/src/command-internal/docker-*.ts", - "!apps/cli/src/command-internal/dotenv.*", - "!apps/cli/src/command-internal/drop-objects.ts", - "!apps/cli/src/command-internal/edge-runtime-*.ts", - "!apps/cli/src/command-internal/ensure-login.*", - "!apps/cli/src/command-internal/error-message.ts", - "!apps/cli/src/command-internal/experimental-feature.ts", - "!apps/cli/src/command-internal/experimental-gate.*", - "!apps/cli/src/command-internal/fail-on.*", - "!apps/cli/src/command-internal/get-api-keys.*", - "!apps/cli/src/command-internal/get-tenant-api-keys.*", - "!apps/cli/src/command-internal/git-status.*", - "!apps/cli/src/command-internal/glob.*", - "!apps/cli/src/command-internal/global-flags.*", - "!apps/cli/src/command-internal/go-duration.*", - "!apps/cli/src/command-internal/go-float.*", - "!apps/cli/src/command-internal/go-json.*", - "!apps/cli/src/command-internal/go-jwt.*", - "!apps/cli/src/command-internal/go-output-flag.*", - "!apps/cli/src/command-internal/go-output.encoders.*", - "!apps/cli/src/command-internal/go-quote.ts", - "!apps/cli/src/command-internal/go-struct-output.*", - "!apps/cli/src/command-internal/hostname.*", - "!apps/cli/src/command-internal/http-*", - "!apps/cli/src/command-internal/identity-stitch.*", - "!apps/cli/src/command-internal/kong-*", - "!apps/cli/src/command-internal/link-services-core.*", - "!apps/cli/src/command-internal/linked-state.*", - "!apps/cli/src/command-internal/local-project-context.*", - "!apps/cli/src/command-internal/login-*", - "!apps/cli/src/command-internal/make-dir.*", - "!apps/cli/src/command-internal/management-api-runtime.*", - "!apps/cli/src/command-internal/migrate-and-seed.*", - "!apps/cli/src/command-internal/migration*", - "!apps/cli/src/command-internal/param-introspection.*", - "!apps/cli/src/command-internal/parent-project-ref.*", - "!apps/cli/src/command-internal/parse-uint.*", - "!apps/cli/src/command-internal/password-requirements.ts", - "!apps/cli/src/command-internal/path-match.*", - "!apps/cli/src/command-internal/pflag-reconcile.*", - "!apps/cli/src/command-internal/pg-dump.*", - "!apps/cli/src/command-internal/pg-net-guidance.*", - "!apps/cli/src/command-internal/pgdelta*", - "!apps/cli/src/command-internal/pgpass.*", - "!apps/cli/src/command-internal/pgservicefile.*", - "!apps/cli/src/command-internal/pooler-fallback.*", - "!apps/cli/src/command-internal/postgres-client.run.*", - "!apps/cli/src/command-internal/postgres-url.*", - "!apps/cli/src/command-internal/profile-load.*", - "!apps/cli/src/command-internal/profile.*", - "!apps/cli/src/command-internal/project-create-core.*", - "!apps/cli/src/command-internal/project-environment.*", - "!apps/cli/src/command-internal/project-target.*", - "!apps/cli/src/command-internal/prompt-yes-no.*", - "!apps/cli/src/command-internal/raw-http.*", - "!apps/cli/src/command-internal/ref-patterns.*", - "!apps/cli/src/command-internal/region.format.*", - "!apps/cli/src/command-internal/resolve-token.*", - "!apps/cli/src/command-internal/rune-width.*", - "!apps/cli/src/command-internal/schema-flags.*", - "!apps/cli/src/command-internal/seed*", - "!apps/cli/src/command-internal/service-catalog.*", - "!apps/cli/src/command-internal/service-version-overrides.*", - "!apps/cli/src/command-internal/shell-quote.ts", - "!apps/cli/src/command-internal/size-units.ts", - "!apps/cli/src/command-internal/sql-files-glob.*", - "!apps/cli/src/command-internal/sql-split.*", - "!apps/cli/src/command-internal/stack-*", - "!apps/cli/src/command-internal/start-*", - "!apps/cli/src/command-internal/status-errors.ts", - "!apps/cli/src/command-internal/status-pretty.*", - "!apps/cli/src/command-internal/status-values.*", - "!apps/cli/src/command-internal/storage-*.ts", - "!apps/cli/src/command-internal/string-slice-flag.*", - "!apps/cli/src/command-internal/temp-paths.*", - "!apps/cli/src/command-internal/tenant-*", - "!apps/cli/src/command-internal/test-db.*", - "!apps/cli/src/command-internal/timestamp.format.*", - "!apps/cli/src/command-internal/upgrade-notice.*", - "!apps/cli/src/command-internal/upgrade-suggest.*", - "!apps/cli/src/command-internal/vault-decrypt.*", - "!apps/cli/src/command-internal/vault.*", - "!apps/cli/src/command-internal/viper-env.*", - "!apps/cli/src/command-internal/workdir-project.*", - "!apps/cli/src/command-internal/workdir-search.ts", - "!apps/cli/src/command-internal/workdir-validation.ts", - "apps/cli/src/command-internal/config-validate.parity.unit.test.ts", - "apps/cli/src/command-internal/config-validate.ts", - "apps/cli/src/command-internal/config-validate.unit.test.ts", - "apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts", - "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts" + "!apps/cli/tests/helpers/storage-live.ts" ] } diff --git a/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts b/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts index d619b6b5e3..4c72b7b8d2 100644 --- a/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts @@ -1,47 +1,54 @@ import * as BunPath from "@effect/platform-bun/BunPath"; -import { Effect, Path } from "effect"; -import { describe, expect, it, vi } from "vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import { describe, expect, it } from "vitest"; + +import { resolveEmailTemplateContentPath } from "./config-validate.ts"; /** * Exercises `canonicalPathForContainment`'s ancestor walk-up with a 5,000-component missing * path — deeper than any real filesystem allows (`PATH_MAX` caps a real OS at a few thousand - * components), so `node:fs` is mocked here to drive the loop's own iteration count directly. - * Isolated into its own file because `vi.mock` is file-scoped and would break - * `config-validate.unit.test.ts`'s reliance on a real filesystem. + * components), so `FileSystem` is faked here to drive the loop's own iteration count directly. */ -vi.mock("node:fs", () => ({ - realpathSync: vi.fn((path: string) => { - if (path === FAKE_EXISTING_BASE) return FAKE_EXISTING_BASE; - throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { +const missingEntry = (method: string, syscall: string, path: string) => + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method, + syscall, + pathOrDescriptor: path, + cause: Object.assign(new Error(`ENOENT: no such file or directory, ${syscall} '${path}'`), { code: "ENOENT", - }); - }), - lstatSync: vi.fn(() => undefined), - readlinkSync: vi.fn(() => { - throw new Error("readlinkSync should never be reached — no path in this fixture is a symlink"); - }), - statSync: vi.fn(() => { - throw new Error("statSync should never be reached by the template content_path branch"); - }), -})); + }), + }); const FAKE_EXISTING_BASE = "/fake/project-root"; const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); +const fakeFileSystem = FileSystem.layerNoop({ + realPath: (path) => + path === FAKE_EXISTING_BASE + ? Effect.succeed(FAKE_EXISTING_BASE) + : Effect.fail(missingEntry("realPath", "realpath", path)), + /* The `lstat` stand-in: no path in this fixture exists, so none is a symlink. */ + readLink: (path) => Effect.fail(missingEntry("readLink", "readlink", path)), + stat: () => + Effect.die(new Error("`stat` should never be reached by the template content_path branch")), +}); describe("canonicalPathForContainment (via resolveEmailTemplateContentPath)", () => { - it("resolves a 5,000-component-deep missing content_path without blowing the call stack", () => - import("./config-validate.ts").then(({ resolveEmailTemplateContentPath }) => { - const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`); - const contentPath = `${missingSegments.join("/")}/invite.html`; + it("resolves a 5,000-component-deep missing content_path without blowing the call stack", () => { + const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`); + const contentPath = `${missingSegments.join("/")}/invite.html`; - const resolved = resolveEmailTemplateContentPath({ + const resolved = Effect.runSync( + resolveEmailTemplateContentPath({ section: "template", name: "invite", contentPath, contentPresent: false, base: FAKE_EXISTING_BASE, - }); + }).pipe(Effect.provide(Layer.mergeAll(fakeFileSystem, BunPath.layer))), + ); - expect(resolved).toBe(path.join(FAKE_EXISTING_BASE, contentPath)); - })); + expect(resolved).toBe(path.join(FAKE_EXISTING_BASE, contentPath)); + }); }); diff --git a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts index 17b8f86b53..78c39b877f 100644 --- a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts @@ -1,13 +1,10 @@ import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; -import { withEnvVar, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; +import { withConfigEnv, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { readDbToml } from "./db-config.toml-read.ts"; import { resolveStorageCredentials } from "./storage-credentials.ts"; @@ -28,12 +25,21 @@ import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; * the `baseConfig`/`WORKDIR` pattern from `local-config-values.unit.test.ts`. */ -function withConfig(content: string) { - const dir = mkdtempSync(join(tmpdir(), "config-validate-parity-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "config.toml"), content); +const withConfig = Effect.fnUntraced(function* (content: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "config-validate-parity-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", "config.toml"), content); return dir; -} +}); + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const removeDir = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(dir, { recursive: true, force: true }); + }); const readD = (workdir: string) => Effect.gen(function* () { @@ -45,14 +51,14 @@ const readD = (workdir: string) => /** Drives D's real pipeline and asserts the failure message contains `message`. */ function failsWithD(tomlLines: ReadonlyArray, message: string) { return Effect.gen(function* () { - const dir = withConfig(tomlLines.join("\n")); + const dir = yield* withConfig(tomlLines.join("\n")); const exit = yield* readD(dir).pipe(Effect.exit); expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain(message); + expect(yield* encodeJson(exit.cause)).toContain(message); } - rmSync(dir, { recursive: true, force: true }); - }); + yield* removeDir(dir); + }).pipe(Effect.provide(BunServices.layer)); } const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); @@ -63,16 +69,19 @@ function baseConfig(overrides: Record = {}): CliConfig { } /** Drives L's real pipeline and asserts the failure message contains `message`. */ -function failsWithL( +const failsWithL = Effect.fnUntraced(function* ( overrides: Record, message: string, document?: Readonly>, ) { const config = baseConfig(overrides); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)).toThrow( - message, + const error = yield* Effect.flip( + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document).pipe( + Effect.provide(BunServices.layer), + ), ); -} + expect(error.message).toContain(message); +}); interface ParityScenario { readonly name: string; @@ -303,7 +312,7 @@ describe("validateResolvedConfig cross-caller parity (D vs L)", () => { it.effect(`${scenario.name}: D and L fail with the same message`, () => Effect.gen(function* () { yield* failsWithD(scenario.toml, scenario.message); - failsWithL(scenario.overrides, scenario.message, scenario.document); + yield* failsWithL(scenario.overrides, scenario.message, scenario.document); }), ); } @@ -317,7 +326,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", /** Drives S's real pipeline (`resolveStorageCredentials`, local branch) to failure. */ const failsWithS = (config: CliConfig, message: string) => Effect.gen(function* () { - const dir = withConfig(""); + const dir = yield* withConfig(""); const exit = yield* resolveStorageCredentials({ projectRef: "", config }).pipe( Effect.provide( Layer.mergeAll( @@ -336,30 +345,35 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", ); expect(Exit.isFailure(exit), `S: expected failure containing: ${message}`).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain(message); + expect(yield* encodeJson(exit.cause)).toContain(message); } - rmSync(dir, { recursive: true, force: true }); - }); + yield* removeDir(dir); + }).pipe(Effect.provide(BunServices.layer)); // Ambient SUPABASE_API_* / SUPABASE_AUTH_* values would override the config // under test, so pin every participating key to unset for the duration of // each scenario. const isolated = (body: Effect.Effect) => - [ - "SUPABASE_API_PORT", - "SUPABASE_API_ENABLED", - "SUPABASE_API_TLS_ENABLED", - "SUPABASE_API_TLS_CERT_PATH", - "SUPABASE_API_TLS_KEY_PATH", - "SUPABASE_AUTH_JWT_SECRET", - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - ].reduce((inner, name) => withEnvVar(name, undefined, inner), body); + withConfigEnv( + Object.fromEntries( + [ + "SUPABASE_API_PORT", + "SUPABASE_API_ENABLED", + "SUPABASE_API_TLS_ENABLED", + "SUPABASE_API_TLS_CERT_PATH", + "SUPABASE_API_TLS_KEY_PATH", + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + ].map((name) => [name, ""]), + ), + body, + ); it.effect("api.port = 0 with the API enabled: S and L fail with the same message", () => isolated( Effect.gen(function* () { const message = "Missing required field in config: api.port"; - failsWithL({ api: { port: 0 } }, message); + yield* failsWithL({ api: { port: 0 } }, message); yield* failsWithS(baseConfig({ api: { port: 0 } }), message); }), ), @@ -369,7 +383,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", isolated( Effect.gen(function* () { const message = "Missing required field in config: api.tls.key_path"; - failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); + yield* failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); yield* failsWithS( baseConfig({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }), message, @@ -382,7 +396,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", isolated( Effect.gen(function* () { const message = "Invalid config for auth.jwt_secret. Must be at least 16 characters"; - failsWithL({ auth: { jwt_secret: "short" } }, message); + yield* failsWithL({ auth: { jwt_secret: "short" } }, message); yield* failsWithS(baseConfig({ auth: { jwt_secret: "short" } }), message); }), ), @@ -395,7 +409,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", Effect.gen(function* () { const message = "failed to parse config"; const auth = { service_role_key: "encrypted:not-a-real-ciphertext" }; - failsWithL({ auth }, message); + yield* failsWithL({ auth }, message); yield* failsWithS(baseConfig({ auth }), message); }), ), diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index 9549248426..4b49d32a7b 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -1,5 +1,4 @@ -import { statSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { Data, Effect, FileSystem, Path, type PlatformError } from "effect"; import { actionability, @@ -16,8 +15,8 @@ import { goUrlParse } from "./storage-url.ts"; * * - **D** = `db-config.toml-read.ts`: raw TOML document, Effect-based, fails with * `DbConfigLoadError`. - * - **L** = `local-config-values.ts`: decoded `@supabase/config` `CliConfig`, synchronous, - * throws `Error`. + * - **L** = `local-config-values.ts`: decoded `@supabase/config` `CliConfig`, Effect-based, + * fails with an `Error`. * * Per-command reimplementations of any branch {@link validateResolvedConfig} owns are forbidden; * hoist here instead. A few branches stay caller-only: `remotes[*].project_id` and @@ -62,12 +61,16 @@ export function parseGoBool(value: string): boolean | undefined { } /** - * Thrown by {@link validateResolvedConfig}. Does not override `.name` — it stays the inherited + * Raised by {@link validateResolvedConfig} (thrown) and by the `content_path`/secret resolvers + * (failed). Does not override `.name` — it stays the inherited * `"Error"` — so `instanceof Error` and `.name` checks can't distinguish it from a plain * `Error`; only `.message` is meant to be observed. */ -export class ConfigValidateError extends Error { +export class ConfigValidateError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "ConfigValidateError"; + constructor(message: string) { + super({ message }); + } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } @@ -229,6 +232,10 @@ function messageOf(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } +/** The `node:fs` error behind a `FileSystem` failure, whose message the config errors quote. */ +export const fsErrorCause = (error: PlatformError.PlatformError): unknown => + error.reason.cause ?? error.reason.description; + /** * The canonical `api.port` branch: an enabled API with a port of `0` is * invalid config. Exported so the storage-credentials resolver @@ -581,8 +588,14 @@ function isValidJson(value: string): boolean { } /** Absolute → verbatim; relative → join(workdir, "supabase", p). */ -export function resolveSigningKeysPath(workdir: string, signingKeysPath: string): string { - return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +export function resolveSigningKeysPath( + path: Path.Path, + workdir: string, + signingKeysPath: string, +): string { + return path.isAbsolute(signingKeysPath) + ? signingKeysPath + : path.join(workdir, "supabase", signingKeysPath); } /** `failed to read signing keys: ${msg(cause)}` */ @@ -598,7 +611,7 @@ export function signingKeysDecodeErrorMessage(cause: unknown): string { // with the first key — that JWK-specific decode/signing logic stays in L. /** - * Pure exclusivity decision plus the path to read for one template/notification entry. Throws + * Exclusivity decision plus the path to read for one template/notification entry. Fails with * {@link ConfigValidateError} when `contentPath === ""` and `contentPresent` (`content_path` * is required instead of `content`). Returns the absolute, canonicalized path to read, or * `undefined` when there's nothing to read; a set `contentPath` always wins over `content` @@ -606,10 +619,10 @@ export function signingKeysDecodeErrorMessage(cause: unknown): string { * * The resolved candidate and `base` are both canonicalized and the candidate must resolve * inside `base` — an absolute path, a `..` escape, or an in-root symlink pointing outside the - * project root all throw. This applies unconditionally to every caller (config validation, + * project root all fail. This applies unconditionally to every caller (config validation, * `config push` content loading, `start`'s pre-Docker containment pass) — there is no opt-out. */ -export function resolveEmailTemplateContentPath(args: { +export const resolveEmailTemplateContentPath = Effect.fnUntraced(function* (args: { readonly section: "template" | "notification"; readonly name: string; /** Post-env-expand; `""` = absent. */ @@ -617,35 +630,36 @@ export function resolveEmailTemplateContentPath(args: { /** Raw `content` key present in the TOML document. */ readonly contentPresent: boolean; readonly base: string; -}): string | undefined { +}) { if (args.contentPath.length === 0) { if (args.contentPresent) { - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, ); } return undefined; } + const path = yield* Path.Path; const candidate = args.section === "notification" - ? resolveNotificationContentPath(args.base, args.contentPath) - : isAbsolute(args.contentPath) + ? yield* resolveNotificationContentPath(args.base, args.contentPath) + : path.isAbsolute(args.contentPath) ? args.contentPath - : join(args.base, args.contentPath); - const resolvedCanonical = canonicalPathForContainment(candidate); - const canonicalBase = canonicalPathForContainment(args.base); - if (!isPathContainedInRoot(canonicalBase, resolvedCanonical)) { + : path.join(args.base, args.contentPath); + const resolvedCanonical = yield* canonicalPathForContainment(candidate); + const canonicalBase = yield* canonicalPathForContainment(args.base); + if (!isPathContainedInRoot(path, canonicalBase, resolvedCanonical)) { // Echoes the declared value, not `resolvedCanonical` — the declared value is already known // to the user (it's literally in config.toml or an env override they set), while echoing // the symlink-dereferenced target back would let a hostile config probe where it resolves. - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Invalid config for auth.email.${args.section}.${args.name}.content_path: ` + `"${args.contentPath}" resolves outside the project root ${args.base} — ` + `move the file inside the project, or use a relative path that stays inside it.`, ); } return resolvedCanonical; -} +}); /** * Notification `content_path` resolution with a legacy fallback: older scaffolds documented @@ -656,15 +670,19 @@ export function resolveEmailTemplateContentPath(args: { * {@link probeFile}). Shared by config validation, `config push` content loading, and the Kong * template mount builder so every consumer sees the same file. */ -function resolveNotificationContentPath(base: string, contentPath: string): string { - if (isAbsolute(contentPath)) return contentPath; - const resolved = join(base, contentPath); - if (probeFile(resolved) === "missing") { - const fallbackResolved = join(base, "supabase", contentPath); - if (probeFile(fallbackResolved) === "exists") return fallbackResolved; +const resolveNotificationContentPath = Effect.fnUntraced(function* ( + base: string, + contentPath: string, +) { + const path = yield* Path.Path; + if (path.isAbsolute(contentPath)) return contentPath; + const resolved = path.join(base, contentPath); + if ((yield* probeFile(resolved)) === "missing") { + const fallbackResolved = path.join(base, "supabase", contentPath); + if ((yield* probeFile(fallbackResolved)) === "exists") return fallbackResolved; } return resolved; -} +}); /** * Tri-state existence probe for the notification legacy-fallback decision above: `"exists"` @@ -675,15 +693,20 @@ function resolveNotificationContentPath(base: string, contentPath: string): stri * must only be selected on a confirmed `"exists"`. A single boolean can't express both * defaults, so the tri-state lets each caller choose. */ -function probeFile(path: string): "exists" | "missing" | "unknown" { - try { - const stats = statSync(path, { throwIfNoEntry: false }); - if (stats === undefined) return "missing"; - return stats.isFile() ? "exists" : "missing"; - } catch { - return "unknown"; - } -} +const probeFile = Effect.fnUntraced(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(path).pipe( + Effect.map((info): "exists" | "missing" | "unknown" => + info.type === "File" ? "exists" : "missing", + ), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.succeed("missing" as const), + () => Effect.succeed("unknown" as const), + ), + ); +}); /** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ export function emailContentPathReadErrorMessage( @@ -697,8 +720,8 @@ export function emailContentPathReadErrorMessage( // ── api.tls cert/key (path rule: NO isAbsolute guard) ── /** Unconditional join(workdir, "supabase", p) — `path.Join` absorbs a leading "/" too. */ -export function resolveApiTlsPath(workdir: string, p: string): string { - return join(workdir, "supabase", p); +export function resolveApiTlsPath(path: Path.Path, workdir: string, p: string): string { + return path.join(workdir, "supabase", p); } /** `failed to read TLS cert: ${msg(cause)}` */ diff --git a/apps/cli/src/command-internal/config-validate.unit.test.ts b/apps/cli/src/command-internal/config-validate.unit.test.ts index 6140bcd76f..9e9917bc23 100644 --- a/apps/cli/src/command-internal/config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.unit.test.ts @@ -1,16 +1,7 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readdirSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it, layer } from "@effect/vitest"; +import { Effect, FileSystem, Path, PlatformError, Predicate } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { BUCKET_NAME_PATTERN, @@ -106,33 +97,31 @@ describe("CLERK_DOMAIN_PATTERN", () => { // Direct coverage for the containment check shared by every caller (config push's // loadAuthEmailContent, db-config.toml-read.ts, local-config-values.ts, start.handler.ts's // pre-Docker pass), cheaper to pin here than to re-derive through every caller's own fixtures. -describe("resolveEmailTemplateContentPath", () => { - let projectRoot = ""; - let outsideDir = ""; - - afterEach(() => { - if (projectRoot.length > 0) { - rmSync(projectRoot, { recursive: true, force: true }); - projectRoot = ""; - } - if (outsideDir.length > 0) { - rmSync(outsideDir, { recursive: true, force: true }); - outsideDir = ""; - } - }); +layer(BunServices.layer)("resolveEmailTemplateContentPath", (it) => { + const tempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix }); + }); - function setup(): string { - projectRoot = mkdtempSync(join(tmpdir(), "config-validate-email-content-")); - return projectRoot; - } + const setup = () => tempDir("config-validate-email-content-"); /** A real file outside `base`, so a containment test proves the escape check fires rather than a missing-file error. */ - function setupOutsideFile(): string { - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const outsideFile = join(outsideDir, "secret.html"); - writeFileSync(outsideFile, "

Outside

"); + const setupOutsideFile = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const outsideFile = path.join(outsideDir, "secret.html"); + yield* fs.writeFileString(outsideFile, "

Outside

"); return outsideFile; - } + }); + + /** Whether `chmod 000` actually blocks reading `dir` here (it doesn't when running as root). */ + const permissionEnforced = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return !(yield* fs.readDirectory(dir).pipe(Effect.isSuccess)); + }); function resolveContentPath( section: "template" | "notification", @@ -148,252 +137,361 @@ describe("resolveEmailTemplateContentPath", () => { }); } - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects an absolute %s content_path outside the project root", - (section) => { - const base = setup(); - const outsideFile = setupOutsideFile(); + (section) => + Effect.gen(function* () { + const base = yield* setup(); + const outsideFile = yield* setupOutsideFile; - expect(() => resolveContentPath(section, outsideFile, base)).toThrow(ConfigValidateError); - expect(() => resolveContentPath(section, outsideFile, base)).toThrow( - /resolves outside the project root/, - ); - }, + expect(yield* Effect.flip(resolveContentPath(section, outsideFile, base))).toBeInstanceOf( + ConfigValidateError, + ); + expect( + (yield* Effect.flip(resolveContentPath(section, outsideFile, base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects a relative %s content_path that escapes the project root via ..", - (section) => { - const base = setup(); - const outsideFile = setupOutsideFile(); - const escapePath = relative(base, outsideFile); - - expect(() => resolveContentPath(section, escapePath, base)).toThrow( - /resolves outside the project root/, - ); - }, + (section) => + Effect.gen(function* () { + const path = yield* Path.Path; + const base = yield* setup(); + const outsideFile = yield* setupOutsideFile; + const escapePath = path.relative(base, outsideFile); + + expect((yield* Effect.flip(resolveContentPath(section, escapePath, base))).message).toMatch( + /resolves outside the project root/, + ); + }), ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects a %s content_path that is an in-root symlink pointing outside the project root", - (section) => { - const base = setup(); - const outsideFile = setupOutsideFile(); - const symlinkPath = join(base, "evil.html"); - symlinkSync(outsideFile, symlinkPath); - - expect(() => resolveContentPath(section, "./evil.html", base)).toThrow( - /resolves outside the project root/, - ); - }, + (section) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideFile = yield* setupOutsideFile; + const symlinkPath = path.join(base, "evil.html"); + yield* fs.symlink(outsideFile, symlinkPath); + + expect( + (yield* Effect.flip(resolveContentPath(section, "./evil.html", base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it("accepts an in-root sibling path whose name literally starts with two dots, distinct from a .. escape", () => { - const base = setup(); - const dotDir = join(base, "..templates"); - mkdirSync(dotDir, { recursive: true }); - writeFileSync(join(dotDir, "invite.html"), "

Invite

"); - - const resolved = resolveContentPath("template", "..templates/invite.html", base); - - expect(resolved).toBe(join(realpathSync(base), "..templates", "invite.html")); - }); + it.effect( + "accepts an in-root sibling path whose name literally starts with two dots, distinct from a .. escape", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const dotDir = path.join(base, "..templates"); + yield* fs.makeDirectory(dotDir, { recursive: true }); + yield* fs.writeFileString(path.join(dotDir, "invite.html"), "

Invite

"); + + const resolved = yield* resolveContentPath("template", "..templates/invite.html", base); + + expect(resolved).toBe(path.join(yield* fs.realPath(base), "..templates", "invite.html")); + }), + ); - it("accepts a content_path that resolves to exactly the project root", () => { - const base = setup(); + it.effect("accepts a content_path that resolves to exactly the project root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const base = yield* setup(); - const resolved = resolveContentPath("template", ".", base); + const resolved = yield* resolveContentPath("template", ".", base); - expect(resolved).toBe(realpathSync(base)); - }); + expect(resolved).toBe(yield* fs.realPath(base)); + }), + ); - it("resolves a missing in-root file behind a symlinked project root instead of raising the containment error", () => { - const realDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-real-")); - const linkContainer = mkdtempSync(join(tmpdir(), "config-validate-email-content-link-")); - const symlinkedRoot = join(linkContainer, "project-root"); - symlinkSync(realDir, symlinkedRoot, "dir"); - - try { - const resolved = resolveContentPath("template", "missing-invite.html", symlinkedRoot); - expect(resolved).toBe(join(realpathSync(symlinkedRoot), "missing-invite.html")); - } finally { - rmSync(linkContainer, { recursive: true, force: true }); - rmSync(realDir, { recursive: true, force: true }); - } - }); + it.effect( + "resolves a missing in-root file behind a symlinked project root instead of raising the containment error", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* tempDir("config-validate-email-content-real-"); + const linkContainer = yield* tempDir("config-validate-email-content-link-"); + const symlinkedRoot = path.join(linkContainer, "project-root"); + yield* fs.symlink(realDir, symlinkedRoot); + + const resolved = yield* resolveContentPath( + "template", + "missing-invite.html", + symlinkedRoot, + ); + expect(resolved).toBe(path.join(yield* fs.realPath(symlinkedRoot), "missing-invite.html")); + }), + ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects a %s content_path that is an in-root dangling symlink pointing to a nonexistent target outside the project root", - (section) => { - const base = setup(); - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const neverCreatedOutsideTarget = join(outsideDir, "never-created.html"); - const danglingSymlinkPath = join(base, "dangling.html"); - symlinkSync(neverCreatedOutsideTarget, danglingSymlinkPath); - - expect(() => resolveContentPath(section, "./dangling.html", base)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./dangling.html", base)).toThrow( - /resolves outside the project root/, - ); - }, + (section) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const neverCreatedOutsideTarget = path.join(outsideDir, "never-created.html"); + const danglingSymlinkPath = path.join(base, "dangling.html"); + yield* fs.symlink(neverCreatedOutsideTarget, danglingSymlinkPath); + + expect( + yield* Effect.flip(resolveContentPath(section, "./dangling.html", base)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./dangling.html", base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it.each(["template", "notification"] as const)( - "rejects a %s content_path that is an in-root symlink whose outside target sits behind an unsearchable (EACCES) directory", - (section) => { - // Skipped when this environment doesn't enforce chmod 000 (e.g. running as root); the - // dangling-symlink case above already covers the core regression without a permission trick. - const base = setup(); - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const unsearchableDir = join(outsideDir, "locked"); - mkdirSync(unsearchableDir); - const target = join(unsearchableDir, "secret.html"); - writeFileSync(target, "

Locked

"); - chmodSync(unsearchableDir, 0o000); - - try { - let permissionEnforced = true; - try { - readdirSync(unsearchableDir); - permissionEnforced = false; - } catch { - // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. - } - if (!permissionEnforced) { + it.effect.each(["template", "notification"] as const)( + "rejects a %s content_path that is an in-root dangling symlink whose own mode denies reading it", + (section) => + Effect.gen(function* () { + /* Skipped where symlink modes can't be set or aren't enforced (Linux, root). */ + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const lockedSymlinkPath = path.join(base, "locked.html"); + yield* fs.symlink(path.join(outsideDir, "never-created.html"), lockedSymlinkPath); + const locked = yield* spawner + .exitCode(ChildProcess.make("chmod", ["-h", "000", lockedSymlinkPath])) + .pipe( + Effect.map((exitCode) => exitCode === 0), + Effect.orElseSucceed(() => false), + ); + if (!locked || (yield* fs.readLink(lockedSymlinkPath).pipe(Effect.isSuccess))) { return; } - const symlinkPath = join(base, "unsearchable.html"); - symlinkSync(target, symlinkPath); + const failure = yield* Effect.flip(resolveContentPath(section, "./locked.html", base)); + expect( + failure instanceof PlatformError.PlatformError && + Predicate.isTagged(failure.reason, "PermissionDenied"), + ).toBe(true); + }), + ); - expect(() => resolveContentPath(section, "./unsearchable.html", base)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./unsearchable.html", base)).toThrow( - /resolves outside the project root/, - ); - } finally { - chmodSync(unsearchableDir, 0o755); - } - }, + /* Linux ignores a symlink's own mode, so a `readLink` denied by the symlink itself is driven + through a `FileSystem` that denies it, with the parent `stat` passing or failing. */ + const denyingReadLink = (lockedPath: string, parentSearchable: boolean) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentProbe = `${path.dirname(lockedPath)}${path.sep}.`; + const parentInfo = yield* fs.stat(parentProbe); + const denied = (method: string, pathOrDescriptor: string) => + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor, + }); + return FileSystem.FileSystem.of({ + ...fs, + readLink: (p) => (p === lockedPath ? Effect.fail(denied("readLink", p)) : fs.readLink(p)), + stat: (p) => + p !== parentProbe + ? fs.stat(p) + : parentSearchable + ? Effect.succeed(parentInfo) + : Effect.fail(denied("stat", p)), + }); + }); + + it.effect("rejects a content_path whose own readLink is denied under a searchable parent", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const base = yield* setup(); + const lockedFs = yield* denyingReadLink(path.join(base, "locked.html"), true); + + const failure = yield* Effect.flip( + resolveContentPath("template", "./locked.html", base).pipe( + Effect.provideService(FileSystem.FileSystem, lockedFs), + ), + ); + expect( + failure instanceof PlatformError.PlatformError && + Predicate.isTagged(failure.reason, "PermissionDenied"), + ).toBe(true); + }), ); - it("accepts a genuinely in-root file behind an unsearchable (EACCES) directory, even when the project root itself is reached through a symlink", () => { - const realDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-real-")); - const linkContainer = mkdtempSync(join(tmpdir(), "config-validate-email-content-link-")); - const symlinkedRoot = join(linkContainer, "project-root"); - symlinkSync(realDir, symlinkedRoot, "dir"); - const lockedDir = join(realDir, "locked"); - mkdirSync(lockedDir); - const target = join(lockedDir, "invite.html"); - writeFileSync(target, "

Invite

"); - chmodSync(lockedDir, 0o000); - - try { - let permissionEnforced = true; - try { - readdirSync(lockedDir); - permissionEnforced = false; - } catch { - // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. - } - if (!permissionEnforced) { - return; - } - - const resolved = resolveContentPath("template", "./locked/invite.html", symlinkedRoot); - - expect(resolved).toBe(join(realpathSync(symlinkedRoot), "locked", "invite.html")); - } finally { - chmodSync(lockedDir, 0o755); - rmSync(linkContainer, { recursive: true, force: true }); - rmSync(realDir, { recursive: true, force: true }); - } - }); + it.effect("accepts a content_path whose readLink is denied by an unsearchable parent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const lockedFs = yield* denyingReadLink(path.join(base, "locked.html"), false); + + const resolved = yield* resolveContentPath("template", "./locked.html", base).pipe( + Effect.provideService(FileSystem.FileSystem, lockedFs), + ); + expect(resolved).toBe(path.join(yield* fs.realPath(base), "locked.html")); + }), + ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( + "rejects a %s content_path that is an in-root symlink whose outside target sits behind an unsearchable (EACCES) directory", + (section) => + Effect.gen(function* () { + // Skipped when this environment doesn't enforce chmod 000 (e.g. running as root); the + // dangling-symlink case above already covers the core regression without a permission trick. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const unsearchableDir = path.join(outsideDir, "locked"); + yield* fs.makeDirectory(unsearchableDir); + const target = path.join(unsearchableDir, "secret.html"); + yield* fs.writeFileString(target, "

Locked

"); + yield* fs.chmod(unsearchableDir, 0o000); + + yield* Effect.gen(function* () { + if (!(yield* permissionEnforced(unsearchableDir))) { + return; + } + + const symlinkPath = path.join(base, "unsearchable.html"); + yield* fs.symlink(target, symlinkPath); + + expect( + yield* Effect.flip(resolveContentPath(section, "./unsearchable.html", base)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./unsearchable.html", base))).message, + ).toMatch(/resolves outside the project root/); + }).pipe(Effect.ensuring(fs.chmod(unsearchableDir, 0o755).pipe(Effect.orDie))); + }), + ); + + it.effect( + "accepts a genuinely in-root file behind an unsearchable (EACCES) directory, even when the project root itself is reached through a symlink", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* tempDir("config-validate-email-content-real-"); + const linkContainer = yield* tempDir("config-validate-email-content-link-"); + const symlinkedRoot = path.join(linkContainer, "project-root"); + yield* fs.symlink(realDir, symlinkedRoot); + const lockedDir = path.join(realDir, "locked"); + yield* fs.makeDirectory(lockedDir); + const target = path.join(lockedDir, "invite.html"); + yield* fs.writeFileString(target, "

Invite

"); + yield* fs.chmod(lockedDir, 0o000); + + yield* Effect.gen(function* () { + if (!(yield* permissionEnforced(lockedDir))) { + return; + } + + const resolved = yield* resolveContentPath( + "template", + "./locked/invite.html", + symlinkedRoot, + ); + + expect(resolved).toBe( + path.join(yield* fs.realPath(symlinkedRoot), "locked", "invite.html"), + ); + }).pipe(Effect.ensuring(fs.chmod(lockedDir, 0o755).pipe(Effect.orDie))); + }), + ); + + it.effect.each(["template", "notification"] as const)( "rejects a %s content_path that is an in-root symlink pointing to an unstattable (ENAMETOOLONG) target name, without relying on directory permissions", - (section) => { - const base = setup(); - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const tooLongName = `${"a".repeat(300)}.html`; - const symlinkPath = join(base, "toolong.html"); - try { - symlinkSync(join(outsideDir, tooLongName), symlinkPath); - } catch { + (section) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const tooLongName = `${"a".repeat(300)}.html`; + const symlinkPath = path.join(base, "toolong.html"); // Unprivileged symlink creation isn't unconditionally available (e.g. Windows without // Developer Mode/admin) — skip rather than fail an environment that can't set this up. - return; - } + if ( + !(yield* fs + .symlink(path.join(outsideDir, tooLongName), symlinkPath) + .pipe(Effect.isSuccess)) + ) { + return; + } - expect(() => resolveContentPath(section, "./toolong.html", base)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./toolong.html", base)).toThrow( - /resolves outside the project root/, - ); - }, + expect( + yield* Effect.flip(resolveContentPath(section, "./toolong.html", base)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./toolong.html", base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects an in-root %s symlink loop instead of hanging or crashing", - (section) => { - // Reuses the symlinked-root fixture (rather than relying on incidental ambient-tmpdir - // symlinks) so there's a guaranteed canonicalization gap between root and the loop path. - const realDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-real-")); - const linkContainer = mkdtempSync(join(tmpdir(), "config-validate-email-content-link-")); - const symlinkedRoot = join(linkContainer, "project-root"); - symlinkSync(realDir, symlinkedRoot, "dir"); - - try { - const loopA = join(symlinkedRoot, "loop-a.html"); - const loopB = join(symlinkedRoot, "loop-b.html"); - symlinkSync(loopB, loopA); - symlinkSync(loopA, loopB); - - expect(() => resolveContentPath(section, "./loop-a.html", symlinkedRoot)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./loop-a.html", symlinkedRoot)).toThrow( - /resolves outside the project root/, - ); - } finally { - rmSync(linkContainer, { recursive: true, force: true }); - rmSync(realDir, { recursive: true, force: true }); - } - }, + (section) => + Effect.gen(function* () { + // Reuses the symlinked-root fixture (rather than relying on incidental ambient-tmpdir + // symlinks) so there's a guaranteed canonicalization gap between root and the loop path. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* tempDir("config-validate-email-content-real-"); + const linkContainer = yield* tempDir("config-validate-email-content-link-"); + const symlinkedRoot = path.join(linkContainer, "project-root"); + yield* fs.symlink(realDir, symlinkedRoot); + + const loopA = path.join(symlinkedRoot, "loop-a.html"); + const loopB = path.join(symlinkedRoot, "loop-b.html"); + yield* fs.symlink(loopB, loopA); + yield* fs.symlink(loopA, loopB); + + expect( + yield* Effect.flip(resolveContentPath(section, "./loop-a.html", symlinkedRoot)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./loop-a.html", symlinkedRoot))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it("never selects an unstattable notification legacy-fallback twin behind an unsearchable (EACCES) supabase/ directory", () => { - const base = setup(); - const supabaseDir = join(base, "supabase"); - mkdirSync(supabaseDir); - chmodSync(supabaseDir, 0o000); - - try { - let permissionEnforced = true; - try { - readdirSync(supabaseDir); - permissionEnforced = false; - } catch { - // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. - } - if (!permissionEnforced) { - return; - } - - const resolved = resolveContentPath("notification", "notification.html", base); - - // The result must be the root-resolved path, not the unverified legacy twin; this - // function only resolves a path, it doesn't check that the file exists, so a non-throw - // here is expected. - expect(resolved).toBe(join(realpathSync(base), "notification.html")); - } finally { - chmodSync(supabaseDir, 0o755); - } - }); + it.effect( + "never selects an unstattable notification legacy-fallback twin behind an unsearchable (EACCES) supabase/ directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const supabaseDir = path.join(base, "supabase"); + yield* fs.makeDirectory(supabaseDir); + yield* fs.chmod(supabaseDir, 0o000); + + yield* Effect.gen(function* () { + if (!(yield* permissionEnforced(supabaseDir))) { + return; + } + + const resolved = yield* resolveContentPath("notification", "notification.html", base); + + // The result must be the root-resolved path, not the unverified legacy twin; this + // function only resolves a path, it doesn't check that the file exists, so a non-throw + // here is expected. + expect(resolved).toBe(path.join(yield* fs.realPath(base), "notification.html")); + }).pipe(Effect.ensuring(fs.chmod(supabaseDir, 0o755).pipe(Effect.orDie))); + }), + ); }); /** diff --git a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts index 067ce31ae0..cbcba5b4f6 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -67,26 +67,24 @@ export interface DbBootstrapConfig { readonly storageTargetMigration: string; } +const configOverrideMessage = (dottedFieldPath: string, cause: unknown) => + `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`; + /** - * Wraps a synchronous `envOverride*` read that throws on a malformed value into a typed failure - * instead of an untyped Effect defect. + * Maps an `envOverride*` read's malformed-value failure into the caller's typed error instead of + * an untyped Effect defect. * * @param dottedFieldPath - Config path embedded in the error message (`invalid config for * : `). */ -function wrapConfigOverride( +const wrapConfigOverride = ( dottedFieldPath: string, - thunk: () => T, + effect: Effect.Effect, mapConfigError: (message: string) => E, -): Effect.Effect { - return Effect.try({ - try: thunk, - catch: (cause) => - mapConfigError( - `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }); -} +): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => mapConfigError(configOverrideMessage(dottedFieldPath, cause))), + ); /** * Resolves every field {@link startDatabase} needs from `config`/`projectEnvValues`: values @@ -111,33 +109,39 @@ export const resolveDbBootstrapConfig = ( // wins over a conflicting SUPABASE_DB_MAJOR_VERSION. const majorVersion = remoteWins("db.major_version") ? config.db.major_version - : envOverrideMajorVersion(config.db.major_version, projectEnvValues); + : yield* envOverrideMajorVersion(config.db.major_version, projectEnvValues).pipe( + Effect.orDie, + ); // orioledb_version and the four S3 fields feed the Postgres container's image/env directly. - // `envOverride` never throws, so these don't need `wrapConfigOverride`. Same remote-over-env + // `envOverride` never fails, so these don't need `wrapConfigOverride`. Same remote-over-env // precedence as `majorVersion` applies to each. const orioledbVersion = remoteWins("db.orioledb_version") ? config.db.orioledb_version - : envOverrideOrioleDBVersion(config.db.orioledb_version, projectEnvValues); + : yield* envOverrideOrioleDBVersion(config.db.orioledb_version, projectEnvValues); const s3Host = remoteWins("experimental.s3_host") ? config.experimental.s3_host - : envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", config.experimental.s3_host, projectEnvValues); + : yield* envOverride( + "SUPABASE_EXPERIMENTAL_S3_HOST", + config.experimental.s3_host, + projectEnvValues, + ); const s3Region = remoteWins("experimental.s3_region") ? config.experimental.s3_region - : envOverride( + : yield* envOverride( "SUPABASE_EXPERIMENTAL_S3_REGION", config.experimental.s3_region, projectEnvValues, ); const s3AccessKey = remoteWins("experimental.s3_access_key") ? config.experimental.s3_access_key - : envOverride( + : yield* envOverride( "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", config.experimental.s3_access_key, projectEnvValues, ); const s3SecretKey = remoteWins("experimental.s3_secret_key") ? config.experimental.s3_secret_key - : envOverride( + : yield* envOverride( "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", config.experimental.s3_secret_key, projectEnvValues, @@ -148,41 +152,38 @@ export const resolveDbBootstrapConfig = ( // malformed override; it has no `--exclude`/gates equivalent of its own. const realtimeEnabledForSetup = yield* wrapConfigOverride( "realtime.enabled", - () => - remoteWins("realtime.enabled") - ? config.realtime.enabled - : envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), + remoteWins("realtime.enabled") + ? Effect.succeed(config.realtime.enabled) + : envOverrideBool( + "SUPABASE_REALTIME_ENABLED", + config.realtime.enabled, + "realtime.enabled", + projectEnvValues, + ), mapConfigError, ); const storageEnabledForSetup = yield* wrapConfigOverride( "storage.enabled", - () => - remoteWins("storage.enabled") - ? config.storage.enabled - : envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), + remoteWins("storage.enabled") + ? Effect.succeed(config.storage.enabled) + : envOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ), mapConfigError, ); const authEnabledForSetup = yield* wrapConfigOverride( "auth.enabled", - () => - remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ), + remoteWins("auth.enabled") + ? Effect.succeed(config.auth.enabled) + : envOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ), mapConfigError, ); @@ -190,27 +191,29 @@ export const resolveDbBootstrapConfig = ( // overridden value. const realtimeIpVersion = yield* wrapConfigOverride( "realtime.ip_version", - () => { - // `envOverrideRealtimeIpVersion` reads `process.env` unconditionally, so it can't be + Effect.gen(function* () { + // `envOverrideRealtimeIpVersion` reads the environment unconditionally, so it can't be // called at all when the remote block wins — a raw env var would still beat it. The - // throw below only narrows the type; the schema already guarantees one of these values. + // failure below only narrows the type; the schema already guarantees one of these values. if (remoteWins("realtime.ip_version")) { const value = config.realtime.ip_version; if (value !== "IPv4" && value !== "IPv6") { - throw new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); + return yield* new InvalidRealtimeIpVersionEnvOverrideError( + "realtime.ip_version", + value, + ); } return value; } - return envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues); - }, + return yield* envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues); + }), mapConfigError, ); const realtimeMaxHeaderLength = yield* wrapConfigOverride( "realtime.max_header_length", - () => - remoteWins("realtime.max_header_length") - ? config.realtime.max_header_length - : envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), + remoteWins("realtime.max_header_length") + ? Effect.succeed(config.realtime.max_header_length) + : envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), mapConfigError, ); @@ -219,16 +222,15 @@ export const resolveDbBootstrapConfig = ( // validated eagerly here rather than surfacing later inside a container env builder. const storageFileSizeLimit = remoteWins("storage.file_size_limit") ? config.storage.file_size_limit - : (envOverride( + : ((yield* envOverride( "SUPABASE_STORAGE_FILE_SIZE_LIMIT", config.storage.file_size_limit, projectEnvValues, - ) ?? config.storage.file_size_limit); - yield* wrapConfigOverride( - "storage.file_size_limit", - () => ramInBytes(storageFileSizeLimit), - mapConfigError, - ); + )) ?? config.storage.file_size_limit); + yield* Effect.try({ + try: () => ramInBytes(storageFileSizeLimit), + catch: (cause) => mapConfigError(configOverrideMessage("storage.file_size_limit", cause)), + }); // Reads a `supabase/.temp/postgres-version` pin written by `supabase link`; a missing or // unreadable pin falls back to the embedded default, so this never fails. @@ -250,7 +252,11 @@ export const resolveDbBootstrapConfig = ( const dbHealthTimeout = remoteWins("db.health_timeout") ? config.db.health_timeout - : envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues); + : yield* envOverride( + "SUPABASE_DB_HEALTH_TIMEOUT", + config.db.health_timeout, + projectEnvValues, + ); const dbHealthTimeoutSeconds = yield* Effect.try({ try: () => resolveHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), catch: (cause) => diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts index ebf52e7904..c03ebf4fdb 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts @@ -113,18 +113,16 @@ export const buildLocalDbContainerInputs = ( const { config, projectEnvValues, loaded, hostname, projectId } = context; const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); - const values = yield* Effect.try({ - try: () => - resolveLocalConfigValues( - config, - hostname, - workdir, - projectEnvValues, - loaded?.document, - remoteOverrideKeys, - ), - catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), - }); + const values = yield* resolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + loaded?.document, + remoteOverrideKeys, + ).pipe( + Effect.mapError((cause) => mapError(cause instanceof Error ? cause.message : String(cause))), + ); const bootstrapConfig = yield* resolveDbBootstrapConfig( fs, @@ -160,11 +158,11 @@ export const buildLocalDbContainerInputs = ( port: values.dbPort, major_version: bootstrapConfig.majorVersion, orioledb_version: bootstrapConfig.orioledbVersion, - settings: resolveDbSettingsEnvOverrides( + settings: yield* resolveDbSettingsEnvOverrides( config.db.settings, projectEnvValues, remoteOverrideKeys, - ), + ).pipe(Effect.orDie), }, experimental: { ...config.experimental, @@ -223,10 +221,12 @@ export const buildLocalDbContainerInputs = ( remoteOverrideKeys, ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), Effect.mapError((cause) => mapError(cause.message)), ), apiUrl: values.apiUrl, - authExternalUrl: resolveAuthExternalUrl( + authExternalUrl: yield* resolveAuthExternalUrl( loaded?.document, projectEnvValues, remoteOverrideKeys, diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index 2942c5e4b2..6997ef6b94 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -61,23 +61,22 @@ function asRecord(value: unknown): Record | undefined { : undefined; } +const dbConfigOverrideError = (dottedFieldPath: string) => (cause: unknown) => + new DbConfigLoadError({ + message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + /** - * Wraps a synchronous resolver/parser that throws on a malformed config value into a typed - * `DbConfigLoadError` failure — mirrors `commands/start/start.handler.ts`'s identical - * `wrapConfigOverride`: config loading hard-fails on a bad decode before any Docker work runs. + * Maps a config resolver's malformed-value failure into a typed `DbConfigLoadError` — mirrors + * `commands/start/start.handler.ts`'s identical `wrapConfigOverride`: config loading hard-fails + * on a bad decode before any Docker work runs. */ -function wrapDbConfigOverride( - dottedFieldPath: string, - thunk: () => T, -): Effect.Effect { - return Effect.try({ - try: thunk, - catch: (cause) => - new DbConfigLoadError({ - message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); -} +const wrapDbConfigOverride = (dottedFieldPath: string, effect: Effect.Effect) => + effect.pipe(Effect.mapError(dbConfigOverrideError(dottedFieldPath))); + +/** {@link wrapDbConfigOverride} for a synchronous parser that throws on a malformed value. */ +const wrapDbConfigParse = (dottedFieldPath: string, thunk: () => T) => + Effect.try({ try: thunk, catch: dbConfigOverrideError(dottedFieldPath) }); type StartLocalDatabaseStatus = "already-running" | "started"; @@ -133,27 +132,30 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // touched or the already-running check runs. The parsed values are discarded; only the // fail-fast behavior matters. const authDocForValidation = asRecord(loaded?.document?.["auth"]); - const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => + const resolvedEmailForValidation = yield* wrapDbConfigOverride( + "auth.email", resolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.email.max_frequency", () => + yield* wrapDbConfigParse("auth.email.max_frequency", () => parseGoDuration(resolvedEmailForValidation.max_frequency), ); - yield* wrapDbConfigOverride("auth.email.smtp", () => + yield* wrapDbConfigOverride( + "auth.email.smtp", resolveAuthEmailSmtp(authDocForValidation, projectEnvValues), ); - const smsForValidation = yield* wrapDbConfigOverride("auth.sms", () => + const smsForValidation = yield* wrapDbConfigOverride( + "auth.sms", resolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.sms.max_frequency", () => + yield* wrapDbConfigParse("auth.sms.max_frequency", () => parseGoDuration(smsForValidation.max_frequency), ); - const authEnabledForValidation = envOverrideBool( + const authEnabledForValidation = yield* envOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, "auth.enabled", projectEnvValues, - ); + ).pipe(Effect.orDie); if ( authEnabledForValidation && !smsForValidation.twilio.enabled && @@ -161,33 +163,42 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu !smsForValidation.messagebird.enabled && !smsForValidation.textlocal.enabled && !smsForValidation.vonage.enabled && - envOverrideBool( + (yield* envOverrideBool( "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", config.auth.sms.enable_signup, "auth.sms.enable_signup", projectEnvValues, - ) + ).pipe(Effect.orDie)) ) { yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); } - const gotrueSessionsForValidation = resolveGotrueSessions(config.auth.sessions, projectEnvValues); + const gotrueSessionsForValidation = yield* resolveGotrueSessions( + config.auth.sessions, + projectEnvValues, + ); if (gotrueSessionsForValidation?.timebox !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.timebox", () => + yield* wrapDbConfigParse("auth.sessions.timebox", () => parseGoDuration(gotrueSessionsForValidation.timebox!), ); } if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => + yield* wrapDbConfigParse("auth.sessions.inactivity_timeout", () => parseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), ); } - yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => - parseGoDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), + const mfaForValidation = yield* wrapDbConfigOverride( + "auth.mfa.phone.max_frequency", + resolveAuthMfa(config.auth.mfa, projectEnvValues), + ); + yield* wrapDbConfigParse("auth.mfa.phone.max_frequency", () => + parseGoDuration(mfaForValidation.phone.max_frequency), ); - yield* wrapDbConfigOverride("auth.rate_limit", () => + yield* wrapDbConfigOverride( + "auth.rate_limit", resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.jwt_expiry", () => + yield* wrapDbConfigOverride( + "auth.jwt_expiry", envOverrideUint( "SUPABASE_AUTH_JWT_EXPIRY", "auth.jwt_expiry", @@ -195,7 +206,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.enable_signup", () => + yield* wrapDbConfigOverride( + "auth.enable_signup", envOverrideBool( "SUPABASE_AUTH_ENABLE_SIGNUP", config.auth.enable_signup, @@ -203,7 +215,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.enable_anonymous_sign_ins", () => + yield* wrapDbConfigOverride( + "auth.enable_anonymous_sign_ins", envOverrideBool( "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", config.auth.enable_anonymous_sign_ins, @@ -211,7 +224,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.enable_refresh_token_rotation", () => + yield* wrapDbConfigOverride( + "auth.enable_refresh_token_rotation", envOverrideBool( "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", config.auth.enable_refresh_token_rotation, @@ -219,7 +233,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.refresh_token_reuse_interval", () => + yield* wrapDbConfigOverride( + "auth.refresh_token_reuse_interval", envOverrideUint( "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", "auth.refresh_token_reuse_interval", @@ -227,7 +242,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.enable_manual_linking", () => + yield* wrapDbConfigOverride( + "auth.enable_manual_linking", envOverrideBool( "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", config.auth.enable_manual_linking, @@ -235,7 +251,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.minimum_password_length", () => + yield* wrapDbConfigOverride( + "auth.minimum_password_length", envOverrideUint( "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "auth.minimum_password_length", @@ -243,33 +260,39 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("auth.password_requirements", () => + yield* wrapDbConfigOverride( + "auth.password_requirements", envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.web3", () => - resolveGotrueWeb3(config.auth.web3, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.oauth_server", () => + yield* wrapDbConfigOverride("auth.web3", resolveGotrueWeb3(config.auth.web3, projectEnvValues)); + yield* wrapDbConfigOverride( + "auth.oauth_server", resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.passkey", () => + yield* wrapDbConfigOverride( + "auth.passkey", resolveGotruePasskeyWebauthn(loaded?.document, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.external", () => + yield* wrapDbConfigOverride( + "auth.external", resolveAuthExternalProviders(authDocForValidation, config.auth.external, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.third_party", () => + yield* wrapDbConfigOverride( + "auth.third_party", resolveThirdPartyProviders(config.auth.third_party, projectEnvValues), ); - yield* wrapDbConfigOverride("auth.hook", () => + yield* wrapDbConfigOverride( + "auth.hook", resolveAuthHooks(authDocForValidation, config.auth.hook, projectEnvValues), ); - yield* wrapDbConfigOverride("api.enabled", () => + yield* wrapDbConfigOverride( + "api.enabled", envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues), ); - yield* wrapDbConfigOverride("api.tls.enabled", () => + yield* wrapDbConfigOverride( + "api.tls.enabled", envOverrideBool( "SUPABASE_API_TLS_ENABLED", config.api.tls.enabled, @@ -277,14 +300,17 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("api.max_rows", () => + yield* wrapDbConfigOverride( + "api.max_rows", envOverrideApiMaxRows(config.api.max_rows, projectEnvValues), ); - yield* wrapDbConfigOverride("api.port", () => + yield* wrapDbConfigOverride( + "api.port", envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), ); - yield* wrapDbConfigOverride("storage.vector.enabled", () => + yield* wrapDbConfigOverride( + "storage.vector.enabled", envOverrideBool( "SUPABASE_STORAGE_VECTOR_ENABLED", config.storage.vector.enabled, @@ -292,7 +318,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.s3_protocol.enabled", () => + yield* wrapDbConfigOverride( + "storage.s3_protocol.enabled", envOverrideBool( "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", config.storage.s3_protocol.enabled, @@ -300,7 +327,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.analytics.enabled", () => + yield* wrapDbConfigOverride( + "storage.analytics.enabled", envOverrideBool( "SUPABASE_STORAGE_ANALYTICS_ENABLED", config.storage.analytics.enabled, @@ -308,7 +336,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.analytics.max_namespaces", () => + yield* wrapDbConfigOverride( + "storage.analytics.max_namespaces", envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", "storage.analytics.max_namespaces", @@ -316,7 +345,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.analytics.max_tables", () => + yield* wrapDbConfigOverride( + "storage.analytics.max_tables", envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", "storage.analytics.max_tables", @@ -324,7 +354,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.analytics.max_catalogs", () => + yield* wrapDbConfigOverride( + "storage.analytics.max_catalogs", envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", "storage.analytics.max_catalogs", @@ -332,7 +363,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.vector.max_buckets", () => + yield* wrapDbConfigOverride( + "storage.vector.max_buckets", envOverrideUint( "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", "storage.vector.max_buckets", @@ -340,7 +372,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.vector.max_indexes", () => + yield* wrapDbConfigOverride( + "storage.vector.max_indexes", envOverrideUint( "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", "storage.vector.max_indexes", @@ -351,7 +384,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu const imageTransformationSectionPresent = asRecord(asRecord(loaded?.document?.["storage"])?.["image_transformation"]) !== undefined; if (imageTransformationSectionPresent) { - yield* wrapDbConfigOverride("storage.image_transformation.enabled", () => + yield* wrapDbConfigOverride( + "storage.image_transformation.enabled", envOverrideBool( "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", config.storage.image_transformation?.enabled ?? false, @@ -361,7 +395,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ); } - const localSmtpPortForValidation = yield* wrapDbConfigOverride("local_smtp.port", () => + const localSmtpPortForValidation = yield* wrapDbConfigOverride( + "local_smtp.port", envOverridePort( "SUPABASE_LOCAL_SMTP_PORT", config.local_smtp.port, @@ -369,7 +404,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("local_smtp.smtp_port", () => + yield* wrapDbConfigOverride( + "local_smtp.smtp_port", envOverridePort( "SUPABASE_LOCAL_SMTP_SMTP_PORT", config.local_smtp.smtp_port ?? 0, @@ -377,7 +413,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("local_smtp.pop3_port", () => + yield* wrapDbConfigOverride( + "local_smtp.pop3_port", envOverridePort( "SUPABASE_LOCAL_SMTP_POP3_PORT", config.local_smtp.pop3_port ?? 0, @@ -385,7 +422,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("analytics.port", () => + yield* wrapDbConfigOverride( + "analytics.port", envOverridePort( "SUPABASE_ANALYTICS_PORT", config.analytics.port, @@ -393,7 +431,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("analytics.vector_port", () => + yield* wrapDbConfigOverride( + "analytics.vector_port", envOverridePort( "SUPABASE_ANALYTICS_VECTOR_PORT", config.analytics.vector_port ?? 0, @@ -402,7 +441,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ), ); - yield* wrapDbConfigOverride("db.pooler.enabled", () => + yield* wrapDbConfigOverride( + "db.pooler.enabled", envOverrideBool( "SUPABASE_DB_POOLER_ENABLED", config.db.pooler.enabled, @@ -410,7 +450,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("db.pooler.port", () => + yield* wrapDbConfigOverride( + "db.pooler.port", envOverridePort( "SUPABASE_DB_POOLER_PORT", config.db.pooler.port, @@ -418,20 +459,25 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("db.pooler.pool_mode", () => + yield* wrapDbConfigOverride( + "db.pooler.pool_mode", envOverridePoolMode(config.db.pooler.pool_mode, projectEnvValues), ); - yield* wrapDbConfigOverride("db.pooler.default_pool_size", () => + yield* wrapDbConfigOverride( + "db.pooler.default_pool_size", envOverrideDefaultPoolSize(config.db.pooler.default_pool_size, projectEnvValues), ); - yield* wrapDbConfigOverride("db.pooler.max_client_conn", () => + yield* wrapDbConfigOverride( + "db.pooler.max_client_conn", envOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), ); - yield* wrapDbConfigOverride("edge_runtime.policy", () => + yield* wrapDbConfigOverride( + "edge_runtime.policy", envOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), ); - yield* wrapDbConfigOverride("edge_runtime.inspector_port", () => + yield* wrapDbConfigOverride( + "edge_runtime.inspector_port", envOverridePort( "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", config.edge_runtime.inspector_port, @@ -440,18 +486,22 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ), ); - yield* wrapDbConfigOverride("realtime.ip_version", () => + yield* wrapDbConfigOverride( + "realtime.ip_version", envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), ); - yield* wrapDbConfigOverride("realtime.max_header_length", () => + yield* wrapDbConfigOverride( + "realtime.max_header_length", envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), ); - yield* wrapDbConfigOverride("db.settings", () => + yield* wrapDbConfigOverride( + "db.settings", resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), ); - yield* wrapDbConfigOverride("realtime.enabled", () => + yield* wrapDbConfigOverride( + "realtime.enabled", envOverrideBool( "SUPABASE_REALTIME_ENABLED", config.realtime.enabled, @@ -459,7 +509,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.enabled", () => + yield* wrapDbConfigOverride( + "storage.enabled", envOverrideBool( "SUPABASE_STORAGE_ENABLED", config.storage.enabled, @@ -467,23 +518,27 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("storage.file_size_limit", () => - ramInBytes( - envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit, - ), - ); - yield* wrapDbConfigOverride("db.health_timeout", () => - resolveHealthTimeoutSeconds( - envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues) ?? - config.db.health_timeout, - ), + const storageFileSizeLimitForValidation = + (yield* envOverride( + "SUPABASE_STORAGE_FILE_SIZE_LIMIT", + config.storage.file_size_limit, + projectEnvValues, + )) ?? config.storage.file_size_limit; + yield* wrapDbConfigParse("storage.file_size_limit", () => + ramInBytes(storageFileSizeLimitForValidation), + ); + const dbHealthTimeoutForValidation = + (yield* envOverride( + "SUPABASE_DB_HEALTH_TIMEOUT", + config.db.health_timeout, + projectEnvValues, + )) ?? config.db.health_timeout; + yield* wrapDbConfigParse("db.health_timeout", () => + resolveHealthTimeoutSeconds(dbHealthTimeoutForValidation), ); - yield* wrapDbConfigOverride("edge_runtime.enabled", () => + yield* wrapDbConfigOverride( + "edge_runtime.enabled", envOverrideBool( "SUPABASE_EDGE_RUNTIME_ENABLED", config.edge_runtime.enabled, @@ -491,7 +546,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - yield* wrapDbConfigOverride("db.network_restrictions.enabled", () => + yield* wrapDbConfigOverride( + "db.network_restrictions.enabled", envOverrideBool( "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", config.db.network_restrictions.enabled, @@ -502,7 +558,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu const sslEnforcementSectionPresent = asRecord(asRecord(loaded?.document?.["db"])?.["ssl_enforcement"]) !== undefined; if (sslEnforcementSectionPresent) { - yield* wrapDbConfigOverride("db.ssl_enforcement.enabled", () => + yield* wrapDbConfigOverride( + "db.ssl_enforcement.enabled", envOverrideBool( "SUPABASE_DB_SSL_ENFORCEMENT_ENABLED", config.db.ssl_enforcement?.enabled ?? false, @@ -511,7 +568,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ), ); } - const studioEnabledForValidation = yield* wrapDbConfigOverride("studio.enabled", () => + const studioEnabledForValidation = yield* wrapDbConfigOverride( + "studio.enabled", envOverrideBool( "SUPABASE_STUDIO_ENABLED", config.studio.enabled, @@ -519,7 +577,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu projectEnvValues, ), ); - const studioPortForValidation = yield* wrapDbConfigOverride("studio.port", () => + const studioPortForValidation = yield* wrapDbConfigOverride( + "studio.port", envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues), ); if (studioEnabledForValidation && studioPortForValidation === 0) { @@ -528,7 +587,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu }); } const studioApiUrlForValidation = - envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + (yield* envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues)) ?? config.studio.api_url; if (studioEnabledForValidation) { yield* Effect.try({ @@ -539,7 +598,8 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu }), }); } - const localSmtpEnabledForValidation = yield* wrapDbConfigOverride("local_smtp.enabled", () => + const localSmtpEnabledForValidation = yield* wrapDbConfigOverride( + "local_smtp.enabled", envOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", config.local_smtp.enabled, @@ -555,24 +615,24 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // `resolveLocalConfigValues` is the same resolver `buildLocalDbContainerInputs` calls again // below to build the real `values`. Calling it eagerly here too forces its internal - // decode-time throws (auth.captcha, jwt_secret length, signing_keys_path, api.tls cert/key + // decode-time failures (auth.captcha, jwt_secret length, signing_keys_path, api.tls cert/key // reads, auth.external required fields, email/notification template reads) to surface before // the already-running shortcut. The result is discarded; `buildLocalDbContainerInputs` below // re-resolves the real values. - yield* Effect.try({ - try: () => - resolveLocalConfigValues( - config, - hostnameForValidation, - cliSettings.workdir, - projectEnvValues, - loaded?.document, - ), - catch: (cause) => - new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + yield* resolveLocalConfigValues( + config, + hostnameForValidation, + cliSettings.workdir, + projectEnvValues, + loaded?.document, + ).pipe( + Effect.mapError( + (cause) => + new DbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ); // If the db container is already up, tell the caller and stop here. Runs after the config // load/validation above. diff --git a/apps/cli/src/command-internal/db-config.toml-read.ts b/apps/cli/src/command-internal/db-config.toml-read.ts index 5134fbd1b2..bb1436db72 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -1,15 +1,17 @@ import { normalizeDeprecatedOrioleDBVersion } from "@supabase/config/internal"; -import { Config, Effect, Match, type FileSystem, Option, type Path } from "effect"; +import { Config, Effect, Match, FileSystem, Option, Path } from "effect"; import * as SmolToml from "smol-toml"; import { PROJECT_REF_PATTERN, type AnalyticsInput, type AuthInput, type CaptchaInput, + ConfigValidateError, type ConfigValidationInput, type DbInput, emailContentPathReadErrorMessage, type ExperimentalInput, + fsErrorCause, type HookInput, type MfaFactorInput, parseGoBool, @@ -1093,7 +1095,9 @@ export const envOverrideLookup = Effect.fnUntraced(function* ( export const warnDeprecatedOrioleDBVersionEnv = Effect.fnUntraced(function* ( envLookup: DbTomlValues["envLookup"], ) { - const warning = yield* deprecatedOrioleDBVersionEnvWarning(envLookup); + const warning = yield* deprecatedOrioleDBVersionEnvWarning((name) => + Effect.succeed(envLookup(name)), + ); if (warning !== undefined) yield* (yield* Output).raw(`${warning}\n`, "stderr"); }); @@ -1328,7 +1332,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const expandString = (value: unknown): Option.Option => typeof value === "string" ? nonEmptyString(expandEnv(value, lookup)) : Option.none(); const orioledbVersionRaw = - (remoteWins("db.orioledb_version") ? undefined : readOrioleDBVersionEnvOverride(envOverride)) ?? + (remoteWins("db.orioledb_version") + ? undefined + : yield* readOrioleDBVersionEnvOverride((name) => Effect.succeed(envOverride(name)))) ?? db?.["orioledb_version"]; const orioledbVersion = expandString(orioledbVersionRaw); // The defaults-only fallback says nothing about the project's engine. @@ -1569,7 +1575,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const signingKeysPath = str(authRawResolved, "signing_keys_path"); if (signingKeysPath.length > 0) { const keysJson = yield* fs - .readFileString(resolveSigningKeysPath(workdir, signingKeysPath)) + .readFileString(resolveSigningKeysPath(path, workdir, signingKeysPath)) .pipe( Effect.mapError( (cause) => new DbConfigLoadError({ message: signingKeysReadErrorMessage(cause) }), @@ -1647,20 +1653,22 @@ const readDbTomlCore = Effect.fnUntraced(function* ( for (const name of Object.keys(templatesRaw)) { const tmpl = asRecord(templatesRaw[name]); if (tmpl === undefined) continue; - const contentPath = yield* Effect.try({ - try: () => - resolveEmailTemplateContentPath({ - section: "template", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => - new DbConfigLoadError({ + const contentPath = yield* resolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((error) => { + const cause = error instanceof ConfigValidateError ? error : fsErrorCause(error); + return new DbConfigLoadError({ message: cause instanceof Error ? cause.message : String(cause), - }), - }); + }); + }), + ); if (contentPath === undefined) continue; yield* fs.readFileString(contentPath).pipe( Effect.mapError( @@ -1682,20 +1690,22 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ) { continue; } - const contentPath = yield* Effect.try({ - try: () => - resolveEmailTemplateContentPath({ - section: "notification", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => - new DbConfigLoadError({ + const contentPath = yield* resolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((error) => { + const cause = error instanceof ConfigValidateError ? error : fsErrorCause(error); + return new DbConfigLoadError({ message: cause instanceof Error ? cause.message : String(cause), - }), - }); + }); + }), + ); if (contentPath === undefined) continue; yield* fs.readFileString(contentPath).pipe( Effect.mapError( diff --git a/apps/cli/src/command-internal/functions-go-config.ts b/apps/cli/src/command-internal/functions-go-config.ts index d107110529..e0f053b1bc 100644 --- a/apps/cli/src/command-internal/functions-go-config.ts +++ b/apps/cli/src/command-internal/functions-go-config.ts @@ -15,31 +15,27 @@ function toError(cause: unknown): Error { * Delegates to the same `loadLocalProjectContext`/`resolveLocalConfigValues` pair * `start`/`stop`/`status` already use, rather than re-implementing either. Their derived * local-dev values (JWTs, URLs) are discarded here; only `projectId`/`edgeRuntimeDenoVersion` and - * the validation side effect (throws on the first failure) matter to these three commands. + * the validation side effect (fails on the first error) matter to these three commands. */ export const functionsGoConfigCompat: FunctionsGoConfigCompat = { load: ({ projectRoot, projectRef }) => Effect.gen(function* () { const context = yield* loadLocalProjectContext(projectRoot, toError, projectRef); - const validated = yield* Effect.try({ - try: () => - resolveLocalConfigValues( - context.config, - context.hostname, - projectRoot, - context.projectEnvValues, - context.loaded?.document, - // No `[remotes.]` override-tier gating (empty set, the default): the remote - // block already merged over the base config at file level via - // `loadLocalProjectContext`'s `projectRef` threading above. Known narrow divergence: - // an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` still beats a matched remote - // block's own `deno_version` here, since computing the override keys needs - // `db-config.toml-read.ts`'s remote-resolution pipeline, which this path doesn't run. - undefined, - projectRef, - ), - catch: toError, - }); + const validated = yield* resolveLocalConfigValues( + context.config, + context.hostname, + projectRoot, + context.projectEnvValues, + context.loaded?.document, + // No `[remotes.]` override-tier gating (empty set, the default): the remote + // block already merged over the base config at file level via + // `loadLocalProjectContext`'s `projectRef` threading above. Known narrow divergence: + // an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` still beats a matched remote + // block's own `deno_version` here, since computing the override keys needs + // `db-config.toml-read.ts`'s remote-resolution pipeline, which this path doesn't run. + undefined, + projectRef, + ); return { loaded: context.loaded, projectEnvValues: context.projectEnvValues, diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index 8240608de6..678ddb7345 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -1,6 +1,3 @@ -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; - import type { CliConfig } from "@supabase/config"; import { ENV_CAPTURE_REGEX } from "@supabase/config/internal"; import { @@ -15,7 +12,7 @@ import { defaultPublishableKey, defaultSecretKey, } from "../shared/stack-constants.ts"; -import { Context, Effect, Option, Ref, Schema } from "effect"; +import { Context, Data, Effect, FileSystem, Option, Path, Ref, Schema } from "effect"; import * as Base64Url from "effect/encoding/Base64Url"; import { @@ -46,6 +43,7 @@ import { type DbInput, emailContentPathReadErrorMessage, type ExperimentalInput, + fsErrorCause, type HookInput, type LocalSmtpInput, type MfaFactorInput, @@ -63,6 +61,7 @@ import { } from "./config-validate.ts"; import { DEFAULT_SIGNING_KEY, generateAsymmetricGoJwt, generateGoJwt, type Jwk } from "./go-jwt.ts"; import { collectDotenvPrivateKeys, decryptSecret, isEncryptedSecret } from "./vault-decrypt.ts"; +import { shellEnv } from "./viper-env.ts"; /** * Resolves local-dev config values (URLs, ports, keys) for `status`/`stop`, filling in @@ -147,13 +146,13 @@ function apiUrlWithPath(apiExternalUrl: string, path: string): string { } /** - * Thrown by {@link resolveLocalConfigValues} when `auth.jwt_secret` is configured but too + * Raised by {@link resolveLocalConfigValues} when `auth.jwt_secret` is configured but too * short to sign with. Validated at config-load time, before any command renders output. */ -export class InvalidJwtSecretError extends Error { +export class InvalidJwtSecretError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "InvalidJwtSecretError"; constructor() { - super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); + super({ message: "Invalid config for auth.jwt_secret. Must be at least 16 characters" }); this.name = "InvalidJwtSecretError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -165,14 +164,31 @@ export class InvalidJwtSecretError extends Error { const MIN_JWT_SECRET_LENGTH = 16; /** - * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv override doesn't + * A failure that is not one of this module's config errors (a malformed uint override, a + * signing-key error), keeping the original error's name and message and no actionability. + */ +class LocalConfigValuesError extends Data.Error<{ readonly message: string }> { + constructor(message: string, name = "Error") { + super({ message }); + /* Keeps the wrapped error's name (as a non-enumerable own property) and message. */ + if (name !== "Error") Reflect.defineProperty(this, "name", { value: name, writable: true }); + } +} + +const thrownError = (cause: unknown) => + cause instanceof Error + ? new LocalConfigValuesError(cause.message, cause.name) + : new LocalConfigValuesError(String(cause)); + +/** + * Raised by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv override doesn't * parse as a valid port. A malformed port override always hard-fails config loading; there's * no path that reaches `status`/`stop` with one. */ -export class InvalidPortEnvOverrideError extends Error { +export class InvalidPortEnvOverrideError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "InvalidPortEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); + super({ message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port` }); this.name = "InvalidPortEnvOverrideError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -186,63 +202,61 @@ const MAX_PORT = 65535; /** * Port-flavored sibling of {@link envOverride}/{@link envOverrideBool}. Unlike the boolean * sibling, which falls back to the configured value on a malformed override, a bad port - * override throws {@link InvalidPortEnvOverrideError} instead. + * override fails with {@link InvalidPortEnvOverrideError} instead. */ -export function envOverridePort( +export const envOverridePort = Effect.fnUntraced(function* ( name: string, configuredPort: number, dottedFieldPath: string, projectEnvValues: Readonly> | undefined, -): number { - const value = envOverride(name, undefined, projectEnvValues); +) { + const value = yield* envOverride(name, undefined, projectEnvValues); if (value === undefined) return configuredPort; const parsed = parseGoBaseZeroUint(value); if (parsed === undefined || parsed > BigInt(MAX_PORT)) { - throw new InvalidPortEnvOverrideError(dottedFieldPath, value); + return yield* new InvalidPortEnvOverrideError(dottedFieldPath, value); } return Number(parsed); -} +}); + +/** `projectEnvValues` first, then the ambient environment. */ +const lookupEnv = Effect.fnUntraced(function* ( + name: string, + projectEnvValues: Readonly> | undefined, +) { + return projectEnvValues?.[name] ?? (yield* shellEnv(name)); +}); /** * Resolves a `SUPABASE_` override for a config field: checks `projectEnvValues` - * (the project's already-resolved dotenv values) before falling back to `process.env`, - * treating an empty value as unset. + * (the project's already-resolved dotenv values) before falling back to the ambient `Config` + * environment, treating an empty value as unset. * * The resolved value can itself be an `env(VAR)` indirection (e.g. `env(API_ENABLED)`), * resolved with the same precedence; an unresolved indirection is returned as-is. */ -export function envOverride( - name: string, - configured: string, - projectEnvValues: Readonly> | undefined, -): string; -export function envOverride( - name: string, - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined; -export function envOverride( +export const envOverride = Effect.fnUntraced(function* ( name: string, - configured: string | undefined, + configured: Configured, projectEnvValues: Readonly> | undefined, -): string | undefined { - const value = projectEnvValues?.[name] ?? process.env[name]; +) { + const value = yield* lookupEnv(name, projectEnvValues); if (value === undefined || value.length === 0) return configured; const indirection = ENV_CAPTURE_REGEX.exec(value)?.[1]; if (indirection === undefined) return value; - const resolved = projectEnvValues?.[indirection] ?? process.env[indirection]; + const resolved = yield* lookupEnv(indirection, projectEnvValues); return resolved !== undefined && resolved.length > 0 ? resolved : value; -} +}); /** - * Thrown by {@link envOverrideBool} when a `SUPABASE_*_ENABLED` (or other bool-typed) + * Raised by {@link envOverrideBool} when a `SUPABASE_*_ENABLED` (or other bool-typed) * env/dotenv override doesn't parse as an accepted bool spelling. A malformed bool override * always hard-fails config loading, same as {@link InvalidPortEnvOverrideError}. */ -export class InvalidBoolEnvOverrideError extends Error { +export class InvalidBoolEnvOverrideError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "InvalidBoolEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); + super({ message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool` }); this.name = "InvalidBoolEnvOverrideError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -253,31 +267,33 @@ export class InvalidBoolEnvOverrideError extends Error { /** * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields decoded as a * native bool (`api.tls.enabled`, `auth.enabled`, and other `
.enabled` gates). A - * malformed override throws {@link InvalidBoolEnvOverrideError} rather than falling back + * malformed override fails with {@link InvalidBoolEnvOverrideError} rather than falling back * to `configured`. */ -export function envOverrideBool( +export const envOverrideBool = Effect.fnUntraced(function* ( name: string, configured: boolean, dottedFieldPath: string, projectEnvValues: Readonly> | undefined, -): boolean { - const value = envOverride(name, undefined, projectEnvValues); +) { + const value = yield* envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; const parsed = parseGoBool(value); if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); + return yield* new InvalidBoolEnvOverrideError(dottedFieldPath, value); } return parsed; -} +}); -/** Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` isn't `"postgres"` or `"bigquery"`. */ -export class InvalidAnalyticsBackendEnvOverrideError extends Error { +/** Raised by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` isn't `"postgres"` or `"bigquery"`. */ +export class InvalidAnalyticsBackendEnvOverrideError extends Data.Error<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "InvalidAnalyticsBackendEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, - ); + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + }); this.name = "InvalidAnalyticsBackendEnvOverrideError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -291,27 +307,30 @@ export class InvalidAnalyticsBackendEnvOverrideError extends Error { * `skipEnvOverride` lets a matched remote-config value win over a conflicting * `SUPABASE_ANALYTICS_BACKEND`. */ -export function envOverrideAnalyticsBackend( +export const envOverrideAnalyticsBackend = Effect.fnUntraced(function* ( configured: string, projectEnvValues: Readonly> | undefined, skipEnvOverride = false, -): "postgres" | "bigquery" { +) { const value = skipEnvOverride ? configured - : (envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured); + : ((yield* envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues)) ?? + configured); if (value !== "postgres" && value !== "bigquery") { - throw new InvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); + return yield* new InvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); } return value; -} +}); -/** Thrown by {@link envOverrideRealtimeIpVersion} when `SUPABASE_REALTIME_IP_VERSION` isn't `"IPv4"` or `"IPv6"`. */ -export class InvalidRealtimeIpVersionEnvOverrideError extends Error { +/** Raised by {@link envOverrideRealtimeIpVersion} when `SUPABASE_REALTIME_IP_VERSION` isn't `"IPv4"` or `"IPv6"`. */ +export class InvalidRealtimeIpVersionEnvOverrideError extends Data.Error<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "InvalidRealtimeIpVersionEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, - ); + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, + }); this.name = "InvalidRealtimeIpVersionEnvOverrideError"; } @@ -320,46 +339,51 @@ export class InvalidRealtimeIpVersionEnvOverrideError extends Error { } } -export function envOverrideRealtimeIpVersion( +export const envOverrideRealtimeIpVersion = Effect.fnUntraced(function* ( configured: string, projectEnvValues: Readonly> | undefined, -): "IPv4" | "IPv6" { +) { const value = - envOverride("SUPABASE_REALTIME_IP_VERSION", undefined, projectEnvValues) ?? configured; + (yield* envOverride("SUPABASE_REALTIME_IP_VERSION", undefined, projectEnvValues)) ?? configured; if (value !== "IPv4" && value !== "IPv6") { - throw new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); + return yield* new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); } return value; -} +}); /** `SUPABASE_REALTIME_MAX_HEADER_LENGTH` — see {@link envOverrideUint}. */ -export function envOverrideRealtimeMaxHeaderLength( +export const envOverrideRealtimeMaxHeaderLength = Effect.fnUntraced(function* ( configured: number, projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( +) { + return yield* envOverrideUint( "SUPABASE_REALTIME_MAX_HEADER_LENGTH", "realtime.max_header_length", configured, projectEnvValues, ); -} +}); /** `SUPABASE_API_MAX_ROWS` — see {@link envOverrideUint}. */ -export function envOverrideApiMaxRows( +export const envOverrideApiMaxRows = Effect.fnUntraced(function* ( configured: number, projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint("SUPABASE_API_MAX_ROWS", "api.max_rows", configured, projectEnvValues); -} +) { + return yield* envOverrideUint( + "SUPABASE_API_MAX_ROWS", + "api.max_rows", + configured, + projectEnvValues, + ); +}); -/** Thrown by {@link envOverridePoolMode} when `SUPABASE_DB_POOLER_POOL_MODE` isn't `"transaction"` or `"session"`. */ -export class InvalidPoolModeEnvOverrideError extends Error { +/** Raised by {@link envOverridePoolMode} when `SUPABASE_DB_POOLER_POOL_MODE` isn't `"transaction"` or `"session"`. */ +export class InvalidPoolModeEnvOverrideError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "InvalidPoolModeEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, - ); + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, + }); this.name = "InvalidPoolModeEnvOverrideError"; } @@ -368,25 +392,27 @@ export class InvalidPoolModeEnvOverrideError extends Error { } } -export function envOverridePoolMode( +export const envOverridePoolMode = Effect.fnUntraced(function* ( configured: string, projectEnvValues: Readonly> | undefined, -): "transaction" | "session" { +) { const value = - envOverride("SUPABASE_DB_POOLER_POOL_MODE", undefined, projectEnvValues) ?? configured; + (yield* envOverride("SUPABASE_DB_POOLER_POOL_MODE", undefined, projectEnvValues)) ?? configured; if (value !== "transaction" && value !== "session") { - throw new InvalidPoolModeEnvOverrideError("db.pooler.pool_mode", value); + return yield* new InvalidPoolModeEnvOverrideError("db.pooler.pool_mode", value); } return value; -} +}); -/** Thrown by {@link envOverrideEdgeRuntimePolicy} when `SUPABASE_EDGE_RUNTIME_POLICY` isn't `"per_worker"` or `"oneshot"`. */ -export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { +/** Raised by {@link envOverrideEdgeRuntimePolicy} when `SUPABASE_EDGE_RUNTIME_POLICY` isn't `"per_worker"` or `"oneshot"`. */ +export class InvalidEdgeRuntimePolicyEnvOverrideError extends Data.Error<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "InvalidEdgeRuntimePolicyEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, - ); + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, + }); this.name = "InvalidEdgeRuntimePolicyEnvOverrideError"; } @@ -395,43 +421,43 @@ export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { } } -export function envOverrideEdgeRuntimePolicy( +export const envOverrideEdgeRuntimePolicy = Effect.fnUntraced(function* ( configured: string, projectEnvValues: Readonly> | undefined, -): "per_worker" | "oneshot" { +) { const value = - envOverride("SUPABASE_EDGE_RUNTIME_POLICY", undefined, projectEnvValues) ?? configured; + (yield* envOverride("SUPABASE_EDGE_RUNTIME_POLICY", undefined, projectEnvValues)) ?? configured; if (value !== "per_worker" && value !== "oneshot") { - throw new InvalidEdgeRuntimePolicyEnvOverrideError("edge_runtime.policy", value); + return yield* new InvalidEdgeRuntimePolicyEnvOverrideError("edge_runtime.policy", value); } return value; -} +}); /** `SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE` — see {@link envOverrideUint}. */ -export function envOverrideDefaultPoolSize( +export const envOverrideDefaultPoolSize = Effect.fnUntraced(function* ( configured: number, projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( +) { + return yield* envOverrideUint( "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", "db.pooler.default_pool_size", configured, projectEnvValues, ); -} +}); /** `SUPABASE_DB_POOLER_MAX_CLIENT_CONN` — see {@link envOverrideUint}. */ -export function envOverrideMaxClientConn( +export const envOverrideMaxClientConn = Effect.fnUntraced(function* ( configured: number, projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( +) { + return yield* envOverrideUint( "SUPABASE_DB_POOLER_MAX_CLIENT_CONN", "db.pooler.max_client_conn", configured, projectEnvValues, ); -} +}); /** * Decrypts a resolved auth identity-key field when it's a dotenvx `encrypted:` value; an @@ -439,18 +465,18 @@ export function envOverrideMaxClientConn( * Applied after {@link envOverride} so an env-sourced override is decrypted too, not just * the config.toml value. */ -export function decryptAuthSecret( +export const decryptAuthSecret = Effect.fnUntraced(function* ( value: string | undefined, projectEnvValues: Readonly> | undefined, -): string | undefined { +) { if (value === undefined || !isEncryptedSecret(value)) return value; const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnvValues, ...process.env }); const decrypted = decryptSecret(value, dotenvPrivateKeys); if (!decrypted.ok) { - throw new ConfigValidateError(`failed to parse config: ${decrypted.error}`); + return yield* new ConfigValidateError(`failed to parse config: ${decrypted.error}`); } return decrypted.value; -} +}); /** * Resolves `[auth.email.smtp]`'s full field set, including a presence-based `enabled` @@ -458,17 +484,17 @@ export function decryptAuthSecret( * when the key is absent from a present table). Exported so `start.handler.ts`'s GoTrue env * resolution reuses this instead of re-deriving it. */ -export function resolveAuthEmailSmtp( +export const resolveAuthEmailSmtp = Effect.fnUntraced(function* ( authDocument: Readonly> | undefined, projectEnvValues: Readonly> | undefined, /** - * Fields whose `envOverride`/`envOverrideBool`/`envOverridePort` calls below can throw on + * Fields whose `envOverride`/`envOverrideBool`/`envOverridePort` calls below can fail on * a malformed value (directly, or via {@link decryptAuthSecret}) belong in this set so a * matched remote block's value wins instead of aborting on an unrelated bad env var. * Defaults to empty for callers with no `[remotes.]` block to match against. */ remoteOverrideKeys: ReadonlySet = new Set(), -): (SmtpInput & { readonly senderName: string | undefined }) | undefined { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); if (smtpDoc === undefined) return undefined; @@ -477,7 +503,7 @@ export function resolveAuthEmailSmtp( ? smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, "auth.email.smtp.enabled", @@ -487,16 +513,16 @@ export function resolveAuthEmailSmtp( ? typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "" - : (envOverride( + : ((yield* envOverride( "SUPABASE_AUTH_EMAIL_SMTP_HOST", typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", projectEnvValues, - ) ?? ""), + )) ?? ""), port: remoteWins("auth.email.smtp.port") ? typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0 - : envOverridePort( + : yield* envOverridePort( "SUPABASE_AUTH_EMAIL_SMTP_PORT", typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, "auth.email.smtp.port", @@ -506,46 +532,46 @@ export function resolveAuthEmailSmtp( ? typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "" - : (envOverride( + : ((yield* envOverride( "SUPABASE_AUTH_EMAIL_SMTP_USER", typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", projectEnvValues, - ) ?? ""), + )) ?? ""), // Decrypted like other secrets. `auth.email.smtp.pass` is in `ENV_OVERRIDABLE_KEYS` so a // malformed ambient override can't abort decryption when a remote block already set it. pass: remoteWins("auth.email.smtp.pass") - ? (decryptAuthSecret( + ? ((yield* decryptAuthSecret( typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", projectEnvValues, - ) ?? "") - : (decryptAuthSecret( - envOverride( + )) ?? "") + : ((yield* decryptAuthSecret( + (yield* envOverride( "SUPABASE_AUTH_EMAIL_SMTP_PASS", typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", projectEnvValues, - ) ?? "", + )) ?? "", projectEnvValues, - ) ?? ""), + )) ?? ""), adminEmail: remoteWins("auth.email.smtp.admin_email") ? typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "" - : (envOverride( + : ((yield* envOverride( "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", projectEnvValues, - ) ?? ""), + )) ?? ""), senderName: remoteWins("auth.email.smtp.sender_name") ? typeof smtpDoc["sender_name"] === "string" ? smtpDoc["sender_name"] : undefined - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", typeof smtpDoc["sender_name"] === "string" ? smtpDoc["sender_name"] : undefined, projectEnvValues, ), - }; -} + } satisfies SmtpInput & { readonly senderName: string | undefined }; +}); /** * `auth.captcha` validation requires both `provider` and `secret` when the section is @@ -554,26 +580,26 @@ export function resolveAuthEmailSmtp( * `authDocument` instead. Hoisted so `start.handler.ts`'s GoTrue env resolution shares this * same resolved value. */ -export function resolveAuthCaptcha( +export const resolveAuthCaptcha = Effect.fnUntraced(function* ( authDocument: Readonly> | undefined, captcha: CliConfig["auth"]["captcha"], projectEnvValues: Readonly> | undefined, /** - * Fields whose `envOverride`/`envOverrideBool` calls below can throw on a malformed value + * Fields whose `envOverride`/`envOverrideBool` calls below can fail on a malformed value * (directly, or via {@link decryptAuthSecret}) belong in this set so a matched remote * block's value wins instead of aborting on an unrelated bad env var. Defaults to empty for * callers with no `[remotes.]` block to match against. */ remoteOverrideKeys: ReadonlySet = new Set(), -): CaptchaInput | undefined { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const captchaDoc = asRecord(authDocument?.["captcha"]); return captcha - ? { + ? ({ enabled: remoteWins("auth.captcha.enabled") ? (captcha.enabled ?? false) : captchaDoc !== undefined - ? envOverrideBool( + ? yield* envOverrideBool( "SUPABASE_AUTH_CAPTCHA_ENABLED", captcha.enabled ?? false, "auth.captcha.enabled", @@ -583,51 +609,59 @@ export function resolveAuthCaptcha( provider: remoteWins("auth.captcha.provider") ? captcha.provider : captchaDoc !== undefined - ? envOverride("SUPABASE_AUTH_CAPTCHA_PROVIDER", captcha.provider, projectEnvValues) + ? yield* envOverride( + "SUPABASE_AUTH_CAPTCHA_PROVIDER", + captcha.provider, + projectEnvValues, + ) : captcha.provider, // Decrypted like `auth.email.smtp.pass`. `auth.captcha.secret` is in // `ENV_OVERRIDABLE_KEYS` so a malformed ambient override can't abort decryption when a // remote block already set it. - secret: decryptAuthSecret( + secret: yield* decryptAuthSecret( remoteWins("auth.captcha.secret") ? captcha.secret : captchaDoc !== undefined - ? envOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) + ? yield* envOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) : captcha.secret, projectEnvValues, ), - } + } satisfies CaptchaInput) : undefined; -} +}); /** * Resolves the signing secret from the already-resolved `auth.jwt_secret`: empty falls back - * to {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} throws + * to {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} fails with * {@link InvalidJwtSecretError}. */ -export function resolveJwtSecret(configured: string | undefined): string { +export const resolveJwtSecret = Effect.fnUntraced(function* (configured: string | undefined) { if (configured === undefined || configured.length === 0) return defaultJwtSecret; if (configured.length < MIN_JWT_SECRET_LENGTH) { - throw new InvalidJwtSecretError(); + return yield* new InvalidJwtSecretError(); } return configured; -} +}); function resolveOpaqueKey(configured: string | undefined, fallback: string): string { return configured !== undefined && configured.length > 0 ? configured : fallback; } -function resolveSignedKey( +const resolveSignedKey = Effect.fnUntraced(function* ( configured: string | undefined, jwtSecret: string, signingKey: Jwk | undefined, role: "anon" | "service_role", -): string { +) { if (configured !== undefined && configured.length > 0) return configured; - return signingKey !== undefined - ? generateAsymmetricGoJwt(signingKey, role) - : generateGoJwt(jwtSecret, role); -} + return yield* Effect.try({ + try: () => + signingKey !== undefined + ? generateAsymmetricGoJwt(signingKey, role) + : generateGoJwt(jwtSecret, role), + catch: thrownError, + }); +}); /** JWK fields, matching {@link Jwk}. */ const JwkSchema = Schema.Struct({ @@ -649,41 +683,44 @@ const JwkSchema = Schema.Struct({ x: Schema.optionalKey(Schema.String), y: Schema.optionalKey(Schema.String), }); -const decodeJwks = Schema.decodeUnknownSync(Schema.Array(JwkSchema)); +const decodeJwks = Schema.decodeUnknownEffect(Schema.Array(JwkSchema)); /** - * Reads and JSON-decodes `signingKeysPath` into an array of {@link Jwk}, using `node:fs` - * directly (not the `FileSystem` Effect service) to keep this a plain synchronous resolver - * for a rarely-configured field. Callers must only invoke this when auth is enabled. + * Reads and JSON-decodes `signingKeysPath` into an array of {@link Jwk}, keeping a leading BOM + * (no BOM stripping). Callers must only invoke this when auth is enabled. */ -function readSigningKeysFile(workdir: string, signingKeysPath: string): ReadonlyArray { - const absolutePath = resolveSigningKeysPath(workdir, signingKeysPath); - - let contents: string; - try { - contents = readFileSync(absolutePath, "utf8"); - } catch (cause) { - throw new ConfigValidateError(signingKeysReadErrorMessage(cause)); - } +const readSigningKeysFile = Effect.fnUntraced(function* (workdir: string, signingKeysPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = resolveSigningKeysPath(path, workdir, signingKeysPath); + + const contents = yield* fs.readFile(absolutePath).pipe( + Effect.map((bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes)), + Effect.mapError( + (cause) => new ConfigValidateError(signingKeysReadErrorMessage(fsErrorCause(cause))), + ), + ); - try { + const parsed = yield* Effect.try({ // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native parser errors are CLI output; schema decoding discards their messages. - const parsed: unknown = JSON.parse(contents); - // `Jwk.key_ops` is mutable (required for Node's `createPrivateKey`/`JsonWebKey` input), so - // it's copied into a fresh array rather than widening the schema's readonly output type. - return decodeJwks(parsed).map((jwk) => ({ - ...jwk, - key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], - })); - } catch (cause) { - throw new ConfigValidateError(signingKeysDecodeErrorMessage(cause)); - } -} + try: (): unknown => JSON.parse(contents), + catch: (cause) => new ConfigValidateError(signingKeysDecodeErrorMessage(cause)), + }); + const jwks = yield* decodeJwks(parsed).pipe( + Effect.mapError((cause) => new ConfigValidateError(signingKeysDecodeErrorMessage(cause))), + ); + // `Jwk.key_ops` is mutable (required for Node's `createPrivateKey`/`JsonWebKey` input), so + // it's copied into a fresh array rather than widening the schema's readonly output type. + return jwks.map((jwk) => ({ + ...jwk, + key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], + })) satisfies ReadonlyArray; +}); /** See {@link readSigningKeysFile}. */ -function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArray { - return readSigningKeysFile(workdir, signingKeysPath); -} +const loadSigningKeys = Effect.fnUntraced(function* (workdir: string, signingKeysPath: string) { + return yield* readSigningKeysFile(workdir, signingKeysPath); +}); /** * Returns the parsed signing keys only when auth is enabled and a path is configured; @@ -692,16 +729,16 @@ function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArra * on which key(s) apply. `remoteOverrideKeys` lets a matched remote block win over a * conflicting env override on `auth.enabled`/`auth.signing_keys_path`. */ -export function resolveConfiguredSigningKeys( +export const resolveConfiguredSigningKeys = Effect.fnUntraced(function* ( config: CliConfig, workdir: string, projectEnvValues: Readonly> | undefined, remoteOverrideKeys: ReadonlySet = new Set(), -): ReadonlyArray | undefined { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const authEnabled = remoteWins("auth.enabled") ? config.auth.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, "auth.enabled", @@ -709,40 +746,46 @@ export function resolveConfiguredSigningKeys( ); const signingKeysPath = remoteWins("auth.signing_keys_path") ? config.auth.signing_keys_path - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_SIGNING_KEYS_PATH", config.auth.signing_keys_path, projectEnvValues, ); return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 - ? loadSigningKeys(workdir, signingKeysPath) + ? yield* loadSigningKeys(workdir, signingKeysPath) : undefined; -} +}); /** * Confirms `api.tls.cert`/`.key` are readable when both are configured; the "exactly one set" * check lives in `validateResolvedConfig`. Unlike {@link readSigningKeysFile}'s * `signing_keys_path`, both paths join with the `supabase/` dir without an absolute-path guard. */ -function readApiTlsFiles( +const readApiTlsFiles = Effect.fnUntraced(function* ( workdir: string, certPath: string | undefined, keyPath: string | undefined, -): void { +) { if (certPath === undefined || certPath.length === 0) return; if (keyPath === undefined || keyPath.length === 0) return; - try { - readFileSync(resolveApiTlsPath(workdir, certPath), "utf8"); - } catch (cause) { - throw new ConfigValidateError(apiTlsCertReadErrorMessage(cause)); - } - try { - readFileSync(resolveApiTlsPath(workdir, keyPath), "utf8"); - } catch (cause) { - throw new ConfigValidateError(apiTlsKeyReadErrorMessage(cause)); - } -} + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs + .readFile(resolveApiTlsPath(path, workdir, certPath)) + .pipe( + Effect.mapError( + (cause) => new ConfigValidateError(apiTlsCertReadErrorMessage(fsErrorCause(cause))), + ), + ); + yield* fs + .readFile(resolveApiTlsPath(path, workdir, keyPath)) + .pipe( + Effect.mapError( + (cause) => new ConfigValidateError(apiTlsKeyReadErrorMessage(fsErrorCause(cause))), + ), + ); +}); /** * One `[auth.email.template.]` entry, already env-override-resolved. `subject` is @@ -780,18 +823,18 @@ export type ResolvedAuthEmail = Omit | undefined, projectEnvValues: Readonly> | undefined, /** * Fields whose `envOverride`/`envOverrideBool` calls (here or in the caller-side file read - * for `content_path`) can throw on a malformed value belong in this set so a matched remote + * for `content_path`) can fail on a malformed value belong in this set so a matched remote * block's value wins instead of aborting on an unrelated bad env var. Covers top-level * `auth.email.*` leaves as well as each `template..*`/`notification..*` leaf. */ remoteOverrideKeys: ReadonlySet = new Set(), -): ResolvedAuthEmail { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const emailDoc = asRecord(authDocument?.["email"]); const templateDoc = asRecord(emailDoc?.["template"]); @@ -803,12 +846,12 @@ export function resolveAuthEmail( const rawSubjectPresent = asRecord(templateDoc?.[name])?.["subject"] !== undefined; const envSubject = remoteWins(`auth.email.template.${name}.subject`) ? undefined - : envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); + : yield* envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); template[name] = { subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), content_path: remoteWins(`auth.email.template.${name}.content_path`) ? tmpl.content_path - : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + : ((yield* envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues)) ?? tmpl.content_path), // `content` counts as present when set via TOML or the `_CONTENT` env override; // {@link readAuthEmailTemplateContent} rejects it below unless `content_path` is also set. @@ -816,7 +859,8 @@ export function resolveAuthEmail( asRecord(templateDoc?.[name])?.["content"] !== undefined || (remoteWins(`auth.email.template.${name}.content`) ? false - : envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), + : (yield* envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues)) !== + undefined), }; } @@ -826,11 +870,11 @@ export function resolveAuthEmail( const rawSubjectPresent = asRecord(notificationDoc?.[name])?.["subject"] !== undefined; const envSubject = remoteWins(`auth.email.notification.${name}.subject`) ? undefined - : envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); + : yield* envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); notification[name] = { enabled: remoteWins(`auth.email.notification.${name}.enabled`) ? tmpl.enabled - : envOverrideBool( + : yield* envOverrideBool( `${envPrefix}_ENABLED`, tmpl.enabled, `auth.email.notification.${name}.enabled`, @@ -839,14 +883,15 @@ export function resolveAuthEmail( subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), content_path: remoteWins(`auth.email.notification.${name}.content_path`) ? tmpl.content_path - : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + : ((yield* envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues)) ?? tmpl.content_path), // Same `_CONTENT` env-presence fold as the template loop above. content_present: asRecord(notificationDoc?.[name])?.["content"] !== undefined || (remoteWins(`auth.email.notification.${name}.content`) ? false - : envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), + : (yield* envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues)) !== + undefined), }; } @@ -854,7 +899,7 @@ export function resolveAuthEmail( ...email, enable_signup: remoteWins("auth.email.enable_signup") ? email.enable_signup - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", email.enable_signup, "auth.email.enable_signup", @@ -862,7 +907,7 @@ export function resolveAuthEmail( ), double_confirm_changes: remoteWins("auth.email.double_confirm_changes") ? email.double_confirm_changes - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", email.double_confirm_changes, "auth.email.double_confirm_changes", @@ -870,7 +915,7 @@ export function resolveAuthEmail( ), enable_confirmations: remoteWins("auth.email.enable_confirmations") ? email.enable_confirmations - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", email.enable_confirmations, "auth.email.enable_confirmations", @@ -878,7 +923,7 @@ export function resolveAuthEmail( ), secure_password_change: remoteWins("auth.email.secure_password_change") ? email.secure_password_change - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", email.secure_password_change, "auth.email.secure_password_change", @@ -886,11 +931,14 @@ export function resolveAuthEmail( ), max_frequency: remoteWins("auth.email.max_frequency") ? email.max_frequency - : (envOverride("SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", email.max_frequency, projectEnvValues) ?? - email.max_frequency), + : ((yield* envOverride( + "SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", + email.max_frequency, + projectEnvValues, + )) ?? email.max_frequency), otp_length: remoteWins("auth.email.otp_length") ? email.otp_length - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_EMAIL_OTP_LENGTH", "auth.email.otp_length", email.otp_length, @@ -898,7 +946,7 @@ export function resolveAuthEmail( ), otp_expiry: remoteWins("auth.email.otp_expiry") ? email.otp_expiry - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", "auth.email.otp_expiry", email.otp_expiry, @@ -906,8 +954,8 @@ export function resolveAuthEmail( ), template, notification, - }; -} + } satisfies ResolvedAuthEmail; +}); /** * Reads each template/notification's content file, run only when auth is enabled. Every @@ -915,39 +963,61 @@ export function resolveAuthEmail( * `content`-vs-`content_path` exclusivity and path resolution live in * `resolveEmailTemplateContentPath`; this only performs the read once a path comes back. */ -function readAuthEmailTemplateContent(email: ResolvedAuthEmail, workdir: string): void { +const readAuthEmailTemplateContent = Effect.fnUntraced(function* ( + email: ResolvedAuthEmail, + workdir: string, +) { + const fs = yield* FileSystem.FileSystem; for (const [name, tmpl] of Object.entries(email.template)) { - const path = resolveEmailTemplateContentPath({ + const path = yield* resolveEmailTemplateContentPath({ section: "template", name, contentPath: tmpl.content_path, contentPresent: tmpl.content_present, base: workdir, - }); + }).pipe( + Effect.mapError((cause) => + cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), + ), + ); if (path === undefined) continue; - try { - readFileSync(path, "utf8"); - } catch (cause) { - throw new ConfigValidateError(emailContentPathReadErrorMessage("template", name, cause)); - } + yield* fs + .readFile(path) + .pipe( + Effect.mapError( + (cause) => + new ConfigValidateError( + emailContentPathReadErrorMessage("template", name, fsErrorCause(cause)), + ), + ), + ); } for (const [name, tmpl] of Object.entries(email.notification)) { if (!tmpl.enabled) continue; - const path = resolveEmailTemplateContentPath({ + const path = yield* resolveEmailTemplateContentPath({ section: "notification", name, contentPath: tmpl.content_path, contentPresent: tmpl.content_present, base: workdir, - }); + }).pipe( + Effect.mapError((cause) => + cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), + ), + ); if (path === undefined) continue; - try { - readFileSync(path, "utf8"); - } catch (cause) { - throw new ConfigValidateError(emailContentPathReadErrorMessage("notification", name, cause)); - } + yield* fs + .readFile(path) + .pipe( + Effect.mapError( + (cause) => + new ConfigValidateError( + emailContentPathReadErrorMessage("notification", name, fsErrorCause(cause)), + ), + ), + ); } -} +}); // Every field routed through {@link envOverrideUint} is an unsigned 64-bit value; comparing // as `BigInt` (not `Number`) avoids precision loss near `2^64`. @@ -992,33 +1062,35 @@ function parseGoBaseZeroUint(value: string): bigint | undefined { * folds an invalid or out-of-{@link UINT_MAX} override into the generic "Invalid " * error message. */ -export function envOverrideUint( +export const envOverrideUint = Effect.fnUntraced(function* ( name: string, dottedFieldPath: string, configured: number, projectEnvValues: Readonly> | undefined, -): number { - const value = envOverride(name, undefined, projectEnvValues); +) { + const value = yield* envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; const parsed = parseGoBaseZeroUint(value); if (parsed === undefined || parsed > UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); + return yield* new LocalConfigValuesError( + `Failed reading config: Invalid ${dottedFieldPath}: ${value}.`, + ); } return Number(parsed); -} +}); /** `SUPABASE_DB_MAJOR_VERSION` — see {@link envOverrideUint}. */ -export function envOverrideMajorVersion( +export const envOverrideMajorVersion = Effect.fnUntraced(function* ( configured: number, projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( +) { + return yield* envOverrideUint( "SUPABASE_DB_MAJOR_VERSION", "db.major_version", configured, projectEnvValues, ); -} +}); const ORIOLEDB_VERSION_ENV = "SUPABASE_DB_ORIOLEDB_VERSION"; const DEPRECATED_ORIOLEDB_VERSION_ENV = "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"; @@ -1027,22 +1099,25 @@ const DEPRECATED_ORIOLEDB_VERSION_ENV = "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION" * Reads the `db.orioledb_version` override through `read`, falling back to the deprecated * `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` alias while `SUPABASE_DB_ORIOLEDB_VERSION` is unset. */ -export const readOrioleDBVersionEnvOverride = ( +export const readOrioleDBVersionEnvOverride = Effect.fnUntraced(function* ( read: ( name: typeof ORIOLEDB_VERSION_ENV | typeof DEPRECATED_ORIOLEDB_VERSION_ENV, - ) => string | undefined, -): string | undefined => read(ORIOLEDB_VERSION_ENV) ?? read(DEPRECATED_ORIOLEDB_VERSION_ENV); + ) => Effect.Effect, +) { + return (yield* read(ORIOLEDB_VERSION_ENV)) ?? (yield* read(DEPRECATED_ORIOLEDB_VERSION_ENV)); +}); /** `SUPABASE_DB_ORIOLEDB_VERSION` or its deprecated alias — see {@link envOverride}. */ -export function envOverrideOrioleDBVersion( +export const envOverrideOrioleDBVersion = Effect.fnUntraced(function* ( configured: string | undefined, projectEnvValues: Readonly> | undefined, -): string | undefined { +) { return ( - readOrioleDBVersionEnvOverride((name) => envOverride(name, undefined, projectEnvValues)) ?? - configured + (yield* readOrioleDBVersionEnvOverride((name) => + envOverride(name, undefined, projectEnvValues), + )) ?? configured ); -} +}); /** * Set by {@link withSingleOrioleDBVersionEnvWarning}; while unset, every config read that finds the @@ -1063,27 +1138,27 @@ export const withSingleOrioleDBVersionEnvWarning = (effect: Effect.Effe * The deprecation warning to print when `read` finds `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` set, * unless an enclosing {@link withSingleOrioleDBVersionEnvWarning} already printed it. */ -export const deprecatedOrioleDBVersionEnvWarning = Effect.fnUntraced(function* ( - read: (name: typeof DEPRECATED_ORIOLEDB_VERSION_ENV) => string | undefined, +export const deprecatedOrioleDBVersionEnvWarning = Effect.fnUntraced(function* ( + read: (name: typeof DEPRECATED_ORIOLEDB_VERSION_ENV) => Effect.Effect, ) { - if (read(DEPRECATED_ORIOLEDB_VERSION_ENV) === undefined) return undefined; + if ((yield* read(DEPRECATED_ORIOLEDB_VERSION_ENV)) === undefined) return undefined; const printed = yield* OrioleDBVersionEnvWarningPrinted; if (Option.isSome(printed) && (yield* Ref.getAndSet(printed.value, true))) return undefined; return `WARN: ${DEPRECATED_ORIOLEDB_VERSION_ENV} is deprecated and will be removed in v3, use ${ORIOLEDB_VERSION_ENV} instead.`; }); /** `SUPABASE_EDGE_RUNTIME_DENO_VERSION` — see {@link envOverrideUint}. */ -export function envOverrideDenoVersion( +export const envOverrideDenoVersion = Effect.fnUntraced(function* ( configured: number, projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( +) { + return yield* envOverrideUint( "SUPABASE_EDGE_RUNTIME_DENO_VERSION", "edge_runtime.deno_version", configured, projectEnvValues, ); -} +}); /** * Optional-uint sibling of {@link envOverrideUint} for `db.settings.*` fields @@ -1091,48 +1166,52 @@ export function envOverrideDenoVersion( * absent from config.toml. `configured`/the return value stay `number | undefined` to * preserve that state through an override miss. */ -function envOverrideOptionalUint( +const envOverrideOptionalUint = Effect.fnUntraced(function* ( name: string, dottedFieldPath: string, configured: number | undefined, projectEnvValues: Readonly> | undefined, -): number | undefined { - const value = envOverride(name, undefined, projectEnvValues); +) { + const value = yield* envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; const parsed = parseGoBaseZeroUint(value); if (parsed === undefined || parsed > UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); + return yield* new LocalConfigValuesError( + `Failed reading config: Invalid ${dottedFieldPath}: ${value}.`, + ); } return Number(parsed); -} +}); /** * Optional-bool sibling of {@link envOverrideBool} for `db.settings.track_commit_timestamp`, * the only bool field in this file left unset (not defaulted) when absent. */ -function envOverrideOptionalBool( +const envOverrideOptionalBool = Effect.fnUntraced(function* ( name: string, configured: boolean | undefined, dottedFieldPath: string, projectEnvValues: Readonly> | undefined, -): boolean | undefined { - const value = envOverride(name, undefined, projectEnvValues); +) { + const value = yield* envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; const parsed = parseGoBool(value); if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); + return yield* new InvalidBoolEnvOverrideError(dottedFieldPath, value); } return parsed; -} +}); -/** Thrown by {@link resolveDbSettingsEnvOverrides} when `SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE` isn't `"origin"`, `"replica"`, or `"local"`. */ -export class InvalidSessionReplicationRoleEnvOverrideError extends Error { +/** Raised by {@link resolveDbSettingsEnvOverrides} when `SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE` isn't `"origin"`, `"replica"`, or `"local"`. */ +export class InvalidSessionReplicationRoleEnvOverrideError extends Data.Error<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "InvalidSessionReplicationRoleEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, - ); + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, + }); this.name = "InvalidSessionReplicationRoleEnvOverrideError"; } @@ -1146,24 +1225,24 @@ export class InvalidSessionReplicationRoleEnvOverrideError extends Error { * Unlike {@link envOverrideAnalyticsBackend}, `configured` (and the return value) may * be `undefined` — validation only runs once a value is actually present. */ -function envOverrideSessionReplicationRole( +const envOverrideSessionReplicationRole = Effect.fnUntraced(function* ( configured: string | undefined, projectEnvValues: Readonly> | undefined, -): "origin" | "replica" | "local" | undefined { - const value = envOverride( +) { + const value = yield* envOverride( "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", configured, projectEnvValues, ); if (value === undefined) return undefined; if (value !== "origin" && value !== "replica" && value !== "local") { - throw new InvalidSessionReplicationRoleEnvOverrideError( + return yield* new InvalidSessionReplicationRoleEnvOverrideError( "db.settings.session_replication_role", value, ); } return value; -} +}); /** * Resolves every `db.settings.*` sub-field to its env-overridden value before @@ -1171,37 +1250,37 @@ function envOverrideSessionReplicationRole( * lets a matched remote block's value win over a conflicting `SUPABASE_DB_SETTINGS_*` var, * same as elsewhere in this file. */ -export function resolveDbSettingsEnvOverrides( +export const resolveDbSettingsEnvOverrides = Effect.fnUntraced(function* ( settings: CliConfig["db"]["settings"], projectEnvValues: Readonly> | undefined, remoteOverrideKeys: ReadonlySet = new Set(), -): NonNullable { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); return { effective_cache_size: remoteWins("db.settings.effective_cache_size") ? settings?.effective_cache_size - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", settings?.effective_cache_size, projectEnvValues, ), logical_decoding_work_mem: remoteWins("db.settings.logical_decoding_work_mem") ? settings?.logical_decoding_work_mem - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", settings?.logical_decoding_work_mem, projectEnvValues, ), maintenance_work_mem: remoteWins("db.settings.maintenance_work_mem") ? settings?.maintenance_work_mem - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", settings?.maintenance_work_mem, projectEnvValues, ), max_connections: remoteWins("db.settings.max_connections") ? settings?.max_connections - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "db.settings.max_connections", settings?.max_connections, @@ -1209,7 +1288,7 @@ export function resolveDbSettingsEnvOverrides( ), max_locks_per_transaction: remoteWins("db.settings.max_locks_per_transaction") ? settings?.max_locks_per_transaction - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", "db.settings.max_locks_per_transaction", settings?.max_locks_per_transaction, @@ -1217,7 +1296,7 @@ export function resolveDbSettingsEnvOverrides( ), max_parallel_maintenance_workers: remoteWins("db.settings.max_parallel_maintenance_workers") ? settings?.max_parallel_maintenance_workers - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", "db.settings.max_parallel_maintenance_workers", settings?.max_parallel_maintenance_workers, @@ -1225,7 +1304,7 @@ export function resolveDbSettingsEnvOverrides( ), max_parallel_workers: remoteWins("db.settings.max_parallel_workers") ? settings?.max_parallel_workers - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", "db.settings.max_parallel_workers", settings?.max_parallel_workers, @@ -1233,7 +1312,7 @@ export function resolveDbSettingsEnvOverrides( ), max_parallel_workers_per_gather: remoteWins("db.settings.max_parallel_workers_per_gather") ? settings?.max_parallel_workers_per_gather - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", "db.settings.max_parallel_workers_per_gather", settings?.max_parallel_workers_per_gather, @@ -1241,7 +1320,7 @@ export function resolveDbSettingsEnvOverrides( ), max_replication_slots: remoteWins("db.settings.max_replication_slots") ? settings?.max_replication_slots - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", "db.settings.max_replication_slots", settings?.max_replication_slots, @@ -1249,31 +1328,35 @@ export function resolveDbSettingsEnvOverrides( ), max_slot_wal_keep_size: remoteWins("db.settings.max_slot_wal_keep_size") ? settings?.max_slot_wal_keep_size - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", settings?.max_slot_wal_keep_size, projectEnvValues, ), max_standby_archive_delay: remoteWins("db.settings.max_standby_archive_delay") ? settings?.max_standby_archive_delay - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", settings?.max_standby_archive_delay, projectEnvValues, ), max_standby_streaming_delay: remoteWins("db.settings.max_standby_streaming_delay") ? settings?.max_standby_streaming_delay - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", settings?.max_standby_streaming_delay, projectEnvValues, ), max_wal_size: remoteWins("db.settings.max_wal_size") ? settings?.max_wal_size - : envOverride("SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", settings?.max_wal_size, projectEnvValues), + : yield* envOverride( + "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", + settings?.max_wal_size, + projectEnvValues, + ), max_wal_senders: remoteWins("db.settings.max_wal_senders") ? settings?.max_wal_senders - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", "db.settings.max_wal_senders", settings?.max_wal_senders, @@ -1281,7 +1364,7 @@ export function resolveDbSettingsEnvOverrides( ), max_worker_processes: remoteWins("db.settings.max_worker_processes") ? settings?.max_worker_processes - : envOverrideOptionalUint( + : yield* envOverrideOptionalUint( "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", "db.settings.max_worker_processes", settings?.max_worker_processes, @@ -1289,31 +1372,34 @@ export function resolveDbSettingsEnvOverrides( ), session_replication_role: remoteWins("db.settings.session_replication_role") ? settings?.session_replication_role - : envOverrideSessionReplicationRole(settings?.session_replication_role, projectEnvValues), + : yield* envOverrideSessionReplicationRole( + settings?.session_replication_role, + projectEnvValues, + ), shared_buffers: remoteWins("db.settings.shared_buffers") ? settings?.shared_buffers - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", settings?.shared_buffers, projectEnvValues, ), statement_timeout: remoteWins("db.settings.statement_timeout") ? settings?.statement_timeout - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", settings?.statement_timeout, projectEnvValues, ), track_activity_query_size: remoteWins("db.settings.track_activity_query_size") ? settings?.track_activity_query_size - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", settings?.track_activity_query_size, projectEnvValues, ), track_commit_timestamp: remoteWins("db.settings.track_commit_timestamp") ? settings?.track_commit_timestamp - : envOverrideOptionalBool( + : yield* envOverrideOptionalBool( "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", settings?.track_commit_timestamp, "db.settings.track_commit_timestamp", @@ -1321,23 +1407,23 @@ export function resolveDbSettingsEnvOverrides( ), wal_keep_size: remoteWins("db.settings.wal_keep_size") ? settings?.wal_keep_size - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", settings?.wal_keep_size, projectEnvValues, ), wal_sender_timeout: remoteWins("db.settings.wal_sender_timeout") ? settings?.wal_sender_timeout - : envOverride( + : yield* envOverride( "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", settings?.wal_sender_timeout, projectEnvValues, ), work_mem: remoteWins("db.settings.work_mem") ? settings?.work_mem - : envOverride("SUPABASE_DB_SETTINGS_WORK_MEM", settings?.work_mem, projectEnvValues), - }; -} + : yield* envOverride("SUPABASE_DB_SETTINGS_WORK_MEM", settings?.work_mem, projectEnvValues), + } satisfies NonNullable; +}); /** `password_requirements` fixed enum (`@supabase/config`'s `packages/config/src/auth/index.ts`). */ const PASSWORD_REQUIREMENTS_VALUES = new Set([ @@ -1352,16 +1438,22 @@ const PASSWORD_REQUIREMENTS_VALUES = new Set([ * `db start`'s own eager-validation battery can call it directly instead of duplicating the * check. */ -export function envOverrideAuthPasswordRequirements( +export const envOverrideAuthPasswordRequirements = Effect.fnUntraced(function* ( configured: string, projectEnvValues: Readonly> | undefined, -): string { - const override = envOverride("SUPABASE_AUTH_PASSWORD_REQUIREMENTS", undefined, projectEnvValues); +) { + const override = yield* envOverride( + "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", + undefined, + projectEnvValues, + ); if (override !== undefined && !PASSWORD_REQUIREMENTS_VALUES.has(override)) { - throw new Error(`Failed reading config: Invalid auth.password_requirements: ${override}.`); + return yield* new LocalConfigValuesError( + `Failed reading config: Invalid auth.password_requirements: ${override}.`, + ); } return override ?? configured; -} +}); /** Narrows an unknown value to a plain object. */ function asRecord(value: unknown): Record | undefined { @@ -1376,18 +1468,22 @@ function asRecord(value: unknown): Record | undefined { * one-shot auth migration job so both resolve the same value. `remoteOverrideKeys` lets a * matched remote block win over a conflicting `SUPABASE_AUTH_EXTERNAL_URL`. */ -export function resolveAuthExternalUrl( +export const resolveAuthExternalUrl = Effect.fnUntraced(function* ( document: Readonly> | undefined, projectEnvValues: Readonly> | undefined, remoteOverrideKeys: ReadonlySet = new Set(), -): string | undefined { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; const configuredAuthExternalUrl = typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined; if (remoteWins("auth.external_url")) return configuredAuthExternalUrl; - return envOverride("SUPABASE_AUTH_EXTERNAL_URL", configuredAuthExternalUrl, projectEnvValues); -} + return yield* envOverride( + "SUPABASE_AUTH_EXTERNAL_URL", + configuredAuthExternalUrl, + projectEnvValues, + ); +}); /** Hook-type iteration order for {@link resolveAuthHooks}'s output. */ const HOOK_TYPE_ORDER = [ @@ -1415,7 +1511,7 @@ interface ResolvedAuthHook { readonly secrets: string; } -export type ResolvedAuthHooks = { +type ResolvedAuthHooks = { readonly [K in (typeof HOOK_TYPE_TO_CAMEL)[keyof typeof HOOK_TYPE_TO_CAMEL]]: ResolvedAuthHook; }; @@ -1426,18 +1522,18 @@ export type ResolvedAuthHooks = { * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same * result. */ -export function resolveAuthHooks( +export const resolveAuthHooks = Effect.fnUntraced(function* ( authDocument: Readonly> | undefined, hook: CliConfig["auth"]["hook"], projectEnvValues: Readonly> | undefined, /** - * Fields whose overrides below can throw or otherwise cause a downstream rejection on a + * Fields whose overrides below can fail or otherwise cause a downstream rejection on a * malformed value belong in this set so a matched remote block's value wins instead. A * matched remote flattens the whole `auth.hook.` block, so every leaf * (`enabled`/`uri`/`secrets`) is included, not just `enabled`. */ remoteOverrideKeys: ReadonlySet = new Set(), -): ResolvedAuthHooks { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const hookDocument = asRecord(authDocument?.["hook"]); const result = {} as Record; @@ -1448,7 +1544,7 @@ export function resolveAuthHooks( const enabled = remoteWins(`auth.hook.${hookType}.enabled`) ? h.enabled : hookSectionPresent - ? envOverrideBool( + ? yield* envOverrideBool( `${envPrefix}_ENABLED`, h.enabled, `auth.hook.${hookType}.enabled`, @@ -1459,18 +1555,18 @@ export function resolveAuthHooks( (remoteWins(`auth.hook.${hookType}.uri`) ? h.uri : hookSectionPresent - ? envOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) + ? yield* envOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) : h.uri) ?? ""; const secrets = (remoteWins(`auth.hook.${hookType}.secrets`) ? h.secrets : hookSectionPresent - ? envOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) + ? yield* envOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) : h.secrets) ?? ""; result[HOOK_TYPE_TO_CAMEL[hookType]] = { enabled, uri, secrets }; } return result as ResolvedAuthHooks; -} +}); /** * Resolves `auth.mfa`'s per-factor fields. Unlike hooks/smtp, `auth.mfa.` is always @@ -1478,22 +1574,22 @@ export function resolveAuthHooks( * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same * result. */ -export function resolveAuthMfa( +export const resolveAuthMfa = Effect.fnUntraced(function* ( mfa: CliConfig["auth"]["mfa"], projectEnvValues: Readonly> | undefined, /** - * Fields whose `envOverrideBool`/`envOverrideUint`/`envOverride` calls below can throw or + * Fields whose `envOverrideBool`/`envOverrideUint`/`envOverride` calls below can fail or * otherwise cause a downstream rejection on a malformed value belong in this set so a * matched remote block's value wins instead of aborting on an unrelated bad env var. */ remoteOverrideKeys: ReadonlySet = new Set(), -): CliConfig["auth"]["mfa"] { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); return { totp: { enroll_enabled: remoteWins("auth.mfa.totp.enroll_enabled") ? mfa.totp.enroll_enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", mfa.totp.enroll_enabled, "auth.mfa.totp.enroll_enabled", @@ -1501,7 +1597,7 @@ export function resolveAuthMfa( ), verify_enabled: remoteWins("auth.mfa.totp.verify_enabled") ? mfa.totp.verify_enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", mfa.totp.verify_enabled, "auth.mfa.totp.verify_enabled", @@ -1511,7 +1607,7 @@ export function resolveAuthMfa( phone: { enroll_enabled: remoteWins("auth.mfa.phone.enroll_enabled") ? mfa.phone.enroll_enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", mfa.phone.enroll_enabled, "auth.mfa.phone.enroll_enabled", @@ -1519,7 +1615,7 @@ export function resolveAuthMfa( ), verify_enabled: remoteWins("auth.mfa.phone.verify_enabled") ? mfa.phone.verify_enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", mfa.phone.verify_enabled, "auth.mfa.phone.verify_enabled", @@ -1527,7 +1623,7 @@ export function resolveAuthMfa( ), otp_length: remoteWins("auth.mfa.phone.otp_length") ? mfa.phone.otp_length - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", "auth.mfa.phone.otp_length", mfa.phone.otp_length, @@ -1535,20 +1631,23 @@ export function resolveAuthMfa( ), template: remoteWins("auth.mfa.phone.template") ? mfa.phone.template - : (envOverride("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", mfa.phone.template, projectEnvValues) ?? - mfa.phone.template), + : ((yield* envOverride( + "SUPABASE_AUTH_MFA_PHONE_TEMPLATE", + mfa.phone.template, + projectEnvValues, + )) ?? mfa.phone.template), max_frequency: remoteWins("auth.mfa.phone.max_frequency") ? mfa.phone.max_frequency - : (envOverride( + : ((yield* envOverride( "SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", mfa.phone.max_frequency, projectEnvValues, - ) ?? mfa.phone.max_frequency), + )) ?? mfa.phone.max_frequency), }, web_authn: { enroll_enabled: remoteWins("auth.mfa.web_authn.enroll_enabled") ? mfa.web_authn.enroll_enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", mfa.web_authn.enroll_enabled, "auth.mfa.web_authn.enroll_enabled", @@ -1556,7 +1655,7 @@ export function resolveAuthMfa( ), verify_enabled: remoteWins("auth.mfa.web_authn.verify_enabled") ? mfa.web_authn.verify_enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", mfa.web_authn.verify_enabled, "auth.mfa.web_authn.verify_enabled", @@ -1565,143 +1664,134 @@ export function resolveAuthMfa( }, max_enrolled_factors: remoteWins("auth.mfa.max_enrolled_factors") ? mfa.max_enrolled_factors - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", "auth.mfa.max_enrolled_factors", mfa.max_enrolled_factors, projectEnvValues, ), - }; -} + } satisfies CliConfig["auth"]["mfa"]; +}); /** * Resolves `auth.rate_limit.*`, all plain `uint`s with no presence gate: every * `SUPABASE_AUTH_RATE_LIMIT_*` override applies unconditionally. */ -export function resolveGotrueRateLimit( +export const resolveGotrueRateLimit = Effect.fnUntraced(function* ( rateLimit: CliConfig["auth"]["rate_limit"], projectEnvValues: Readonly> | undefined, -): CliConfig["auth"]["rate_limit"] { +) { return { - anonymous_users: envOverrideUint( + anonymous_users: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", "auth.rate_limit.anonymous_users", rateLimit.anonymous_users, projectEnvValues, ), - token_refresh: envOverrideUint( + token_refresh: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_TOKEN_REFRESH", "auth.rate_limit.token_refresh", rateLimit.token_refresh, projectEnvValues, ), - sign_in_sign_ups: envOverrideUint( + sign_in_sign_ups: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_SIGN_IN_SIGN_UPS", "auth.rate_limit.sign_in_sign_ups", rateLimit.sign_in_sign_ups, projectEnvValues, ), - token_verifications: envOverrideUint( + token_verifications: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_TOKEN_VERIFICATIONS", "auth.rate_limit.token_verifications", rateLimit.token_verifications, projectEnvValues, ), - email_sent: envOverrideUint( + email_sent: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT", "auth.rate_limit.email_sent", rateLimit.email_sent, projectEnvValues, ), - sms_sent: envOverrideUint( + sms_sent: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", "auth.rate_limit.sms_sent", rateLimit.sms_sent, projectEnvValues, ), - web3: envOverrideUint( + web3: yield* envOverrideUint( "SUPABASE_AUTH_RATE_LIMIT_WEB3", "auth.rate_limit.web3", rateLimit.web3, projectEnvValues, ), - }; -} + } satisfies CliConfig["auth"]["rate_limit"]; +}); /** * Resolves `auth.sessions.{timebox,inactivity_timeout}`. An env override can introduce a * value even when `[auth.sessions]` was never in config.toml, since `config.auth.sessions` * can be `undefined` here unlike other always-bound sections. */ -export function resolveGotrueSessions( +export const resolveGotrueSessions = Effect.fnUntraced(function* ( sessions: CliConfig["auth"]["sessions"], projectEnvValues: Readonly> | undefined, -): CliConfig["auth"]["sessions"] { - const timebox = envOverride( +) { + const timebox = yield* envOverride( "SUPABASE_AUTH_SESSIONS_TIMEBOX", sessions?.timebox, projectEnvValues, ); - const inactivityTimeout = envOverride( + const inactivityTimeout = yield* envOverride( "SUPABASE_AUTH_SESSIONS_INACTIVITY_TIMEOUT", sessions?.inactivity_timeout, projectEnvValues, ); if (timebox === undefined && inactivityTimeout === undefined) return sessions; - return { timebox, inactivity_timeout: inactivityTimeout }; -} + return { timebox, inactivity_timeout: inactivityTimeout } satisfies CliConfig["auth"]["sessions"]; +}); /** * Resolves `auth.passkey`/`auth.webauthn`, which have no `@supabase/config` schema fields at * all: presence and every field come from the raw TOML document. An absent section is never * synthesized from an env override alone. */ -export function resolveGotruePasskeyWebauthn( +export const resolveGotruePasskeyWebauthn = Effect.fnUntraced(function* ( document: Readonly> | undefined, projectEnvValues: Readonly> | undefined, -): { - readonly passkeyEnabled: boolean | undefined; - readonly webauthn: - | { - readonly rpId: string; - readonly rpDisplayName: string; - readonly rpOrigins: ReadonlyArray; - } - | undefined; -} { +) { const authDoc = asRecord(document?.["auth"]); const passkeyDoc = asRecord(authDoc?.["passkey"]); const webauthnDoc = asRecord(authDoc?.["webauthn"]); const passkeyEnabled = passkeyDoc !== undefined - ? envOverrideBool( + ? yield* envOverrideBool( "SUPABASE_AUTH_PASSKEY_ENABLED", - rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), + yield* rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), "auth.passkey.enabled", projectEnvValues, ) : undefined; const rpOriginsOverride = webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + ? yield* envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) : undefined; const webauthn = webauthnDoc !== undefined ? { rpId: - envOverride( + (yield* envOverride( "SUPABASE_AUTH_WEBAUTHN_RP_ID", typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : "", projectEnvValues, - ) ?? "", + )) ?? "", rpDisplayName: - envOverride( + (yield* envOverride( "SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", typeof webauthnDoc["rp_display_name"] === "string" ? webauthnDoc["rp_display_name"] : "", projectEnvValues, - ) ?? "", + )) ?? "", // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. rpOrigins: (() => { if (rpOriginsOverride !== undefined) return strToArr(rpOriginsOverride); @@ -1714,16 +1804,16 @@ export function resolveGotruePasskeyWebauthn( } : undefined; return { passkeyEnabled, webauthn }; -} +}); /** Resolves `auth.web3.*.enabled`, both value-typed with no presence gate — overrides always apply. */ -export function resolveGotrueWeb3( +export const resolveGotrueWeb3 = Effect.fnUntraced(function* ( web3: CliConfig["auth"]["web3"], projectEnvValues: Readonly> | undefined, -): CliConfig["auth"]["web3"] { +) { return { solana: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", web3.solana.enabled, "auth.web3.solana.enabled", @@ -1731,60 +1821,60 @@ export function resolveGotrueWeb3( ), }, ethereum: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_WEB3_ETHEREUM_ENABLED", web3.ethereum.enabled, "auth.web3.ethereum.enabled", projectEnvValues, ), }, - }; -} + } satisfies CliConfig["auth"]["web3"]; +}); /** Resolves `auth.oauth_server.*`, value-typed with no presence gate — overrides always apply. */ -export function resolveGotrueOAuthServer( +export const resolveGotrueOAuthServer = Effect.fnUntraced(function* ( oauthServer: CliConfig["auth"]["oauth_server"], projectEnvValues: Readonly> | undefined, -): CliConfig["auth"]["oauth_server"] { +) { return { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", oauthServer.enabled, "auth.oauth_server.enabled", projectEnvValues, ), authorization_url_path: - envOverride( + (yield* envOverride( "SUPABASE_AUTH_OAUTH_SERVER_AUTHORIZATION_URL_PATH", oauthServer.authorization_url_path, projectEnvValues, - ) ?? oauthServer.authorization_url_path, - allow_dynamic_registration: envOverrideBool( + )) ?? oauthServer.authorization_url_path, + allow_dynamic_registration: yield* envOverrideBool( "SUPABASE_AUTH_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION", oauthServer.allow_dynamic_registration, "auth.oauth_server.allow_dynamic_registration", projectEnvValues, ), - }; -} + } satisfies CliConfig["auth"]["oauth_server"]; +}); /** * Resolves enabled `auth.third_party.` entries in a fixed order, forwarding only * the enabled ones. `remoteOverrideKeys` lets a matched remote block's value win over a * conflicting `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED`, since `envOverrideBool` would otherwise - * throw and abort the whole call. + * fail and abort the whole call. */ -export function resolveThirdPartyProviders( +export const resolveThirdPartyProviders = Effect.fnUntraced(function* ( thirdParty: CliConfig["auth"]["third_party"], projectEnvValues: Readonly> | undefined, remoteOverrideKeys: ReadonlySet = new Set(), -): ReadonlyArray { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const resolved: Array = []; if ( remoteWins("auth.third_party.firebase.enabled") ? thirdParty.firebase.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", thirdParty.firebase.enabled, "auth.third_party.firebase.enabled", @@ -1796,7 +1886,7 @@ export function resolveThirdPartyProviders( requiredField: (remoteWins("auth.third_party.firebase.project_id") ? thirdParty.firebase.project_id - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", thirdParty.firebase.project_id, projectEnvValues, @@ -1806,7 +1896,7 @@ export function resolveThirdPartyProviders( if ( remoteWins("auth.third_party.auth0.enabled") ? thirdParty.auth0.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", thirdParty.auth0.enabled, "auth.third_party.auth0.enabled", @@ -1818,7 +1908,7 @@ export function resolveThirdPartyProviders( requiredField: (remoteWins("auth.third_party.auth0.tenant") ? thirdParty.auth0.tenant - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", thirdParty.auth0.tenant, projectEnvValues, @@ -1828,7 +1918,7 @@ export function resolveThirdPartyProviders( if ( remoteWins("auth.third_party.aws_cognito.enabled") ? thirdParty.aws_cognito.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", thirdParty.aws_cognito.enabled, "auth.third_party.aws_cognito.enabled", @@ -1840,14 +1930,14 @@ export function resolveThirdPartyProviders( requiredField: (remoteWins("auth.third_party.aws_cognito.user_pool_id") ? thirdParty.aws_cognito.user_pool_id - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", thirdParty.aws_cognito.user_pool_id, projectEnvValues, )) ?? "", cognitoUserPoolRegion: remoteWins("auth.third_party.aws_cognito.user_pool_region") ? thirdParty.aws_cognito.user_pool_region - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", thirdParty.aws_cognito.user_pool_region, projectEnvValues, @@ -1857,7 +1947,7 @@ export function resolveThirdPartyProviders( if ( remoteWins("auth.third_party.clerk.enabled") ? thirdParty.clerk.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", thirdParty.clerk.enabled, "auth.third_party.clerk.enabled", @@ -1869,7 +1959,7 @@ export function resolveThirdPartyProviders( requiredField: (remoteWins("auth.third_party.clerk.domain") ? thirdParty.clerk.domain - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", thirdParty.clerk.domain, projectEnvValues, @@ -1879,7 +1969,7 @@ export function resolveThirdPartyProviders( if ( remoteWins("auth.third_party.workos.enabled") ? thirdParty.workos.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", thirdParty.workos.enabled, "auth.third_party.workos.enabled", @@ -1891,7 +1981,7 @@ export function resolveThirdPartyProviders( requiredField: (remoteWins("auth.third_party.workos.issuer_url") ? thirdParty.workos.issuer_url - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", thirdParty.workos.issuer_url, projectEnvValues, @@ -1899,7 +1989,7 @@ export function resolveThirdPartyProviders( }); } return resolved; -} +}); /** Fixed SMS provider priority — validation stops at the first enabled provider. */ const SMS_PROVIDER_ORDER = [ @@ -1916,18 +2006,18 @@ const SMS_PROVIDER_ORDER = [ * always includes an uncommented `[auth.sms.twilio]` table; the 4 top-level scalars have no * gate for the same reason. `test_otp` (a map) is left unresolved: it has no env var. */ -export function resolveAuthSms( +export const resolveAuthSms = Effect.fnUntraced(function* ( authDocument: Readonly> | undefined, sms: CliConfig["auth"]["sms"], projectEnvValues: Readonly> | undefined, /** - * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed + * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can fail on a malformed * value belong in this set so a matched remote block's value wins instead of aborting on an * unrelated bad env var. Reachable from the `db diff --linked`/`db pull` shadow path via * `validateAuthSmsProviders`, not just this function's direct callers. */ remoteOverrideKeys: ReadonlySet = new Set(), -): CliConfig["auth"]["sms"] { +) { const smsDoc = asRecord(authDocument?.["sms"]); const remoteWins = makeRemoteWins(remoteOverrideKeys); @@ -1937,62 +2027,62 @@ export function resolveAuthSms( return smsDoc !== undefined && asRecord(smsDoc[providerName]) !== undefined; } - function resolveEnabled( + const resolveEnabled = Effect.fnUntraced(function* ( providerName: (typeof SMS_PROVIDER_ORDER)[number], configured: boolean, - ): boolean { + ) { if (remoteWins(`auth.sms.${providerName}.enabled`)) return configured; if (!providerPresent(providerName)) return configured; - return envOverrideBool( + return yield* envOverrideBool( `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_ENABLED`, configured, `auth.sms.${providerName}.enabled`, projectEnvValues, ); - } + }); // `remoteOverrideKey` is explicit (not reconstructed from `providerName`/`field`) since // `field`'s shape differs per provider, and not every combination is a real config key. - function resolveField( + const resolveField = Effect.fnUntraced(function* ( providerName: (typeof SMS_PROVIDER_ORDER)[number], field: string, remoteOverrideKey: RemoteOverridableKey, configured: string | undefined, - ): string | undefined { + ) { if (remoteWins(remoteOverrideKey)) return configured; if (!providerPresent(providerName)) return configured; - return envOverride( + return yield* envOverride( `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_${field.toUpperCase()}`, configured, projectEnvValues, ); - } + }); /** Resolves a provider's Secret-typed field, gated the same way `auth.email.smtp.pass` is. */ - function resolveSecretField( + const resolveSecretField = Effect.fnUntraced(function* ( providerName: (typeof SMS_PROVIDER_ORDER)[number], field: string, remoteOverrideKey: RemoteOverridableKey, configured: string | undefined, - ): string | undefined { + ) { return remoteWins(remoteOverrideKey) - ? decryptAuthSecret(configured, projectEnvValues) - : decryptAuthSecret( - resolveField(providerName, field, remoteOverrideKey, configured), + ? yield* decryptAuthSecret(configured, projectEnvValues) + : yield* decryptAuthSecret( + yield* resolveField(providerName, field, remoteOverrideKey, configured), projectEnvValues, ); - } + }); - const twilioEnabled = resolveEnabled("twilio", sms.twilio.enabled); - const twilioVerifyEnabled = resolveEnabled("twilio_verify", sms.twilio_verify.enabled); - const messagebirdEnabled = resolveEnabled("messagebird", sms.messagebird.enabled); - const textlocalEnabled = resolveEnabled("textlocal", sms.textlocal.enabled); - const vonageEnabled = resolveEnabled("vonage", sms.vonage.enabled); + const twilioEnabled = yield* resolveEnabled("twilio", sms.twilio.enabled); + const twilioVerifyEnabled = yield* resolveEnabled("twilio_verify", sms.twilio_verify.enabled); + const messagebirdEnabled = yield* resolveEnabled("messagebird", sms.messagebird.enabled); + const textlocalEnabled = yield* resolveEnabled("textlocal", sms.textlocal.enabled); + const vonageEnabled = yield* resolveEnabled("vonage", sms.vonage.enabled); const anyProviderEnabled = twilioEnabled || twilioVerifyEnabled || messagebirdEnabled || textlocalEnabled || vonageEnabled; const enableSignupConfigured = remoteWins("auth.sms.enable_signup") ? sms.enable_signup - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", sms.enable_signup, "auth.sms.enable_signup", @@ -2005,7 +2095,7 @@ export function resolveAuthSms( enable_signup: anyProviderEnabled ? enableSignupConfigured : false, enable_confirmations: remoteWins("auth.sms.enable_confirmations") ? sms.enable_confirmations - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", sms.enable_confirmations, "auth.sms.enable_confirmations", @@ -2013,28 +2103,32 @@ export function resolveAuthSms( ), template: remoteWins("auth.sms.template") ? sms.template - : (envOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues) ?? sms.template), + : ((yield* envOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues)) ?? + sms.template), max_frequency: remoteWins("auth.sms.max_frequency") ? sms.max_frequency - : (envOverride("SUPABASE_AUTH_SMS_MAX_FREQUENCY", sms.max_frequency, projectEnvValues) ?? - sms.max_frequency), + : ((yield* envOverride( + "SUPABASE_AUTH_SMS_MAX_FREQUENCY", + sms.max_frequency, + projectEnvValues, + )) ?? sms.max_frequency), twilio: { enabled: twilioEnabled, account_sid: - resolveField( + (yield* resolveField( "twilio", "account_sid", "auth.sms.twilio.account_sid", sms.twilio.account_sid, - ) ?? "", + )) ?? "", message_service_sid: - resolveField( + (yield* resolveField( "twilio", "message_service_sid", "auth.sms.twilio.message_service_sid", sms.twilio.message_service_sid, - ) ?? "", - auth_token: resolveSecretField( + )) ?? "", + auth_token: yield* resolveSecretField( "twilio", "auth_token", "auth.sms.twilio.auth_token", @@ -2043,19 +2137,19 @@ export function resolveAuthSms( }, twilio_verify: { enabled: twilioVerifyEnabled, - account_sid: resolveField( + account_sid: yield* resolveField( "twilio_verify", "account_sid", "auth.sms.twilio_verify.account_sid", sms.twilio_verify.account_sid, ), - message_service_sid: resolveField( + message_service_sid: yield* resolveField( "twilio_verify", "message_service_sid", "auth.sms.twilio_verify.message_service_sid", sms.twilio_verify.message_service_sid, ), - auth_token: resolveSecretField( + auth_token: yield* resolveSecretField( "twilio_verify", "auth_token", "auth.sms.twilio_verify.auth_token", @@ -2064,13 +2158,13 @@ export function resolveAuthSms( }, messagebird: { enabled: messagebirdEnabled, - originator: resolveField( + originator: yield* resolveField( "messagebird", "originator", "auth.sms.messagebird.originator", sms.messagebird.originator, ), - access_key: resolveSecretField( + access_key: yield* resolveSecretField( "messagebird", "access_key", "auth.sms.messagebird.access_key", @@ -2079,13 +2173,13 @@ export function resolveAuthSms( }, textlocal: { enabled: textlocalEnabled, - sender: resolveField( + sender: yield* resolveField( "textlocal", "sender", "auth.sms.textlocal.sender", sms.textlocal.sender, ), - api_key: resolveSecretField( + api_key: yield* resolveSecretField( "textlocal", "api_key", "auth.sms.textlocal.api_key", @@ -2094,72 +2188,81 @@ export function resolveAuthSms( }, vonage: { enabled: vonageEnabled, - from: resolveField("vonage", "from", "auth.sms.vonage.from", sms.vonage.from), - api_key: resolveField("vonage", "api_key", "auth.sms.vonage.api_key", sms.vonage.api_key), - api_secret: resolveSecretField( + from: yield* resolveField("vonage", "from", "auth.sms.vonage.from", sms.vonage.from), + api_key: yield* resolveField( + "vonage", + "api_key", + "auth.sms.vonage.api_key", + sms.vonage.api_key, + ), + api_secret: yield* resolveSecretField( "vonage", "api_secret", "auth.sms.vonage.api_secret", sms.vonage.api_secret, ), }, - }; -} + } satisfies CliConfig["auth"]["sms"]; +}); /** * Validates only the first enabled provider in {@link SMS_PROVIDER_ORDER}; a later * enabled-but-incomplete provider is never checked. Runs against {@link resolveAuthSms}'s * env-override-aware result. */ -function validateAuthSmsProviders( +const validateAuthSmsProviders = Effect.fnUntraced(function* ( authDocument: Record | undefined, sms: CliConfig["auth"]["sms"], projectEnvValues: Readonly> | undefined, remoteOverrideKeys: ReadonlySet = new Set(), -): void { - const resolved = resolveAuthSms(authDocument, sms, projectEnvValues, remoteOverrideKeys); +) { + const resolved = yield* resolveAuthSms(authDocument, sms, projectEnvValues, remoteOverrideKeys); - function requireField(provider: string, field: string, value: string | undefined): void { + const requireField = Effect.fnUntraced(function* ( + provider: string, + field: string, + value: string | undefined, + ) { if (value === undefined || value.length === 0) { - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Missing required field in config: auth.sms.${provider}.${field}`, ); } - } + }); if (resolved.twilio.enabled) { - requireField("twilio", "account_sid", resolved.twilio.account_sid); - requireField("twilio", "message_service_sid", resolved.twilio.message_service_sid); - requireField("twilio", "auth_token", resolved.twilio.auth_token); + yield* requireField("twilio", "account_sid", resolved.twilio.account_sid); + yield* requireField("twilio", "message_service_sid", resolved.twilio.message_service_sid); + yield* requireField("twilio", "auth_token", resolved.twilio.auth_token); return; } if (resolved.twilio_verify.enabled) { - requireField("twilio_verify", "account_sid", resolved.twilio_verify.account_sid); - requireField( + yield* requireField("twilio_verify", "account_sid", resolved.twilio_verify.account_sid); + yield* requireField( "twilio_verify", "message_service_sid", resolved.twilio_verify.message_service_sid, ); - requireField("twilio_verify", "auth_token", resolved.twilio_verify.auth_token); + yield* requireField("twilio_verify", "auth_token", resolved.twilio_verify.auth_token); return; } if (resolved.messagebird.enabled) { - requireField("messagebird", "originator", resolved.messagebird.originator); - requireField("messagebird", "access_key", resolved.messagebird.access_key); + yield* requireField("messagebird", "originator", resolved.messagebird.originator); + yield* requireField("messagebird", "access_key", resolved.messagebird.access_key); return; } if (resolved.textlocal.enabled) { - requireField("textlocal", "sender", resolved.textlocal.sender); - requireField("textlocal", "api_key", resolved.textlocal.api_key); + yield* requireField("textlocal", "sender", resolved.textlocal.sender); + yield* requireField("textlocal", "api_key", resolved.textlocal.api_key); return; } if (resolved.vonage.enabled) { - requireField("vonage", "from", resolved.vonage.from); - requireField("vonage", "api_key", resolved.vonage.api_key); - requireField("vonage", "api_secret", resolved.vonage.api_secret); + yield* requireField("vonage", "from", resolved.vonage.from); + yield* requireField("vonage", "api_key", resolved.vonage.api_key); + yield* requireField("vonage", "api_secret", resolved.vonage.api_secret); return; } -} +}); /** Deleted external providers, warned on if still enabled, never validated. */ const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); @@ -2180,21 +2283,24 @@ export interface ResolvedAuthExternalProvider { * `auth.external` providers, `auth.passkey`/`auth.webauthn`) to a bool, since an `env(VAR)` * substitution there skips normal type coercion and leaves a literal `"true"`/`"false"` * string. A number coerces via truthiness (`!= 0`); an unparsable string or any other type - * throws rather than silently defaulting to `false`. + * fails rather than silently defaulting to `false`. */ -export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boolean { +export const rawUnmodeledBool = Effect.fnUntraced(function* ( + value: unknown, + dottedFieldPath: string, +) { if (value === undefined) return false; if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") { const parsed = parseGoBool(value); if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); + return yield* new InvalidBoolEnvOverrideError(dottedFieldPath, value); } return parsed; } - throw new InvalidBoolEnvOverrideError(dottedFieldPath, String(value)); -} + return yield* new InvalidBoolEnvOverrideError(dottedFieldPath, String(value)); +}); /** * Comma-splits a string into an array (empty string → `[]`), the same rule config decode @@ -2212,18 +2318,18 @@ export function strToArr(value: string): Array { * uncommented. Hoisted so both {@link validateAuthExternalProviders} and `start.handler.ts`'s * GoTrue env resolution share this same result. */ -export function resolveAuthExternalProviders( +export const resolveAuthExternalProviders = Effect.fnUntraced(function* ( authDocument: Readonly> | undefined, external: CliConfig["auth"]["external"], projectEnvValues: Readonly> | undefined, /** - * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed + * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can fail on a malformed * value belong in this set so a matched remote block's value wins instead of aborting on an * unrelated bad env var. Provider names are dynamic, so these keys are tracked via * `applyRemoteOverride` rather than a fixed `ENV_OVERRIDABLE_KEYS` entry. */ remoteOverrideKeys: ReadonlySet = new Set(), -): Record { +) { const remoteWins = makeRemoteWins(remoteOverrideKeys); const externalDoc = asRecord(authDocument?.["external"]); @@ -2240,7 +2346,7 @@ export function resolveAuthExternalProviders( if (provider === undefined && rawProvider === undefined) continue; const configuredEnabled = provider?.enabled ?? - rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`); + (yield* rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`)); const configuredClientId = provider?.client_id ?? (typeof rawProvider?.["client_id"] === "string" ? rawProvider["client_id"] : undefined); @@ -2254,15 +2360,21 @@ export function resolveAuthExternalProviders( (typeof rawProvider?.["redirect_uri"] === "string" ? rawProvider["redirect_uri"] : undefined); const configuredSkipNonceCheck = provider?.skip_nonce_check ?? - rawUnmodeledBool(rawProvider?.["skip_nonce_check"], `auth.external.${name}.skip_nonce_check`); + (yield* rawUnmodeledBool( + rawProvider?.["skip_nonce_check"], + `auth.external.${name}.skip_nonce_check`, + )); const configuredEmailOptional = provider?.email_optional ?? - rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`); + (yield* rawUnmodeledBool( + rawProvider?.["email_optional"], + `auth.external.${name}.email_optional`, + )); result[name] = { enabled: remoteWins(`auth.external.${name}.enabled`) ? configuredEnabled - : envOverrideBool( + : yield* envOverrideBool( `${envPrefix}_ENABLED`, configuredEnabled, `auth.external.${name}.enabled`, @@ -2271,23 +2383,24 @@ export function resolveAuthExternalProviders( clientId: (remoteWins(`auth.external.${name}.client_id`) ? configuredClientId - : envOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues)) ?? "", + : yield* envOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues)) ?? + "", secret: remoteWins(`auth.external.${name}.secret`) - ? decryptAuthSecret(configuredSecret, projectEnvValues) - : decryptAuthSecret( - envOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), + ? yield* decryptAuthSecret(configuredSecret, projectEnvValues) + : yield* decryptAuthSecret( + yield* envOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), projectEnvValues, ), url: (remoteWins(`auth.external.${name}.url`) ? configuredUrl - : envOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues)) ?? "", + : yield* envOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues)) ?? "", redirectUri: remoteWins(`auth.external.${name}.redirect_uri`) ? configuredRedirectUri - : envOverride(`${envPrefix}_REDIRECT_URI`, configuredRedirectUri, projectEnvValues), + : yield* envOverride(`${envPrefix}_REDIRECT_URI`, configuredRedirectUri, projectEnvValues), skipNonceCheck: remoteWins(`auth.external.${name}.skip_nonce_check`) ? configuredSkipNonceCheck - : envOverrideBool( + : yield* envOverrideBool( `${envPrefix}_SKIP_NONCE_CHECK`, configuredSkipNonceCheck, `auth.external.${name}.skip_nonce_check`, @@ -2295,7 +2408,7 @@ export function resolveAuthExternalProviders( ), emailOptional: remoteWins(`auth.external.${name}.email_optional`) ? configuredEmailOptional - : envOverrideBool( + : yield* envOverrideBool( `${envPrefix}_EMAIL_OPTIONAL`, configuredEmailOptional, `auth.external.${name}.email_optional`, @@ -2304,7 +2417,7 @@ export function resolveAuthExternalProviders( }; } return result; -} +}); /** * Validates required fields for every enabled `auth.external.` provider, including @@ -2312,13 +2425,13 @@ export function resolveAuthExternalProviders( * {@link resolveAuthExternalProviders}'s env-override-aware result, since the schema's own * `requiredWhenEnabled` check only sees the pre-override, known-providers-only value. */ -function validateAuthExternalProviders( +const validateAuthExternalProviders = Effect.fnUntraced(function* ( authDocument: Record | undefined, external: CliConfig["auth"]["external"], projectEnvValues: Readonly> | undefined, remoteOverrideKeys: ReadonlySet = new Set(), -): void { - const resolved = resolveAuthExternalProviders( +) { + const resolved = yield* resolveAuthExternalProviders( authDocument, external, projectEnvValues, @@ -2327,7 +2440,7 @@ function validateAuthExternalProviders( for (const [name, provider] of Object.entries(resolved)) { if (!provider.enabled) continue; if (provider.clientId.length === 0) { - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Missing required field in config: auth.external.${name}.client_id`, ); } @@ -2336,36 +2449,36 @@ function validateAuthExternalProviders( name !== "google" && (provider.secret === undefined || provider.secret.length === 0) ) { - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Missing required field in config: auth.external.${name}.secret`, ); } } -} +}); /** - * @throws when `project_id` (post-override, post-workdir-basename-fallback) is an explicit + * Fails when `project_id` (post-override, post-workdir-basename-fallback) is an explicit * empty string. Checked first: the sanitized workdir basename is merged in as a default * before `config.toml`, so `project_id` is never empty by the time validation runs — a * workdir whose basename sanitizes to `""` fails even with no `project_id` key at all. - * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv override - * doesn't parse as a valid bool. - * @throws when a configured `api.tls` cert/key file can't be read — see + * Fails with {@link InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * Fails with {@link InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * Fails with {@link InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * Fails when a configured `api.tls` cert/key file can't be read — see * {@link readApiTlsFiles}. - * @throws when `auth.signing_keys_path` is set, auth is enabled, and the file is missing, + * Fails when `auth.signing_keys_path` is set, auth is enabled, and the file is missing, * malformed, or its first key uses an unsupported algorithm — see * {@link resolveConfiguredSigningKeys} and {@link generateAsymmetricGoJwt}. - * @throws when an email template's `content` is present without `content_path`, or a + * Fails when an email template's `content` is present without `content_path`, or a * configured `content_path` file can't be read — see {@link readAuthEmailTemplateContent}. - * @throws {InvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` doesn't - * parse as one of its accepted values. - * @throws {ConfigValidateError} for every other validation branch, deferred to a single call - * to {@link validateResolvedConfig} at the end of this function. + * Fails with {@link InvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't parse as one of its accepted values. + * Fails with {@link ConfigValidateError} for every other validation branch, deferred to a + * single call to {@link validateResolvedConfig} at the end of this function. */ -export function resolveLocalConfigValues( +export const resolveLocalConfigValues = Effect.fnUntraced(function* ( config: CliConfig, hostname: string, workdir: string, @@ -2378,9 +2491,9 @@ export function resolveLocalConfigValues( document?: Readonly>, /** * Config keys a matched `[remotes.]` block set at override tier. Every field whose - * `envOverride*` call (or a downstream {@link validateResolvedConfig} check) can throw on a - * malformed value belongs in this set — this function returns its whole object or throws, so - * an unconditional throw anywhere aborts the whole call, even for a field not part of the + * `envOverride*` call (or a downstream {@link validateResolvedConfig} check) can fail on a + * malformed value belongs in this set — this function returns its whole object or fails, so + * an unconditional failure anywhere aborts the whole call, even for a field not part of the * returned `LocalConfigValues`. Defaults to empty for callers with no `[remotes.]` block. */ remoteOverrideKeys: ReadonlySet = new Set(), @@ -2389,7 +2502,8 @@ export function resolveLocalConfigValues( * basename fallback applies. `undefined` for `status`/`stop`, which have no such flag. */ projectIdFallback?: string, -): LocalConfigValues { +) { + const path = yield* Path.Path; const remoteWins = makeRemoteWins(remoteOverrideKeys); // Checked first, before every other field: `config.project_id` is `undefined` only when the // key is absent, in which case the sanitized workdir basename (or @@ -2398,18 +2512,18 @@ export function resolveLocalConfigValues( // Not gated by `remoteWins("project_id")` like the fields below: `envOverride` never turns // an already non-empty remote-merged value empty or vice versa, so gating here couldn't // change the accept/reject outcome. - const resolvedProjectId = envOverride( + const resolvedProjectId = yield* envOverride( "SUPABASE_PROJECT_ID", config.project_id ?? (projectIdFallback !== undefined && projectIdFallback.length > 0 ? projectIdFallback - : sanitizeProjectId(basename(workdir))), + : sanitizeProjectId(path.basename(workdir))), projectEnvValues, ); const apiTlsEnabled = remoteWins("api.tls.enabled") ? config.api.tls.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_API_TLS_ENABLED", config.api.tls.enabled, "api.tls.enabled", @@ -2419,26 +2533,35 @@ export function resolveLocalConfigValues( // value, not raw `config.api.enabled`. const apiEnabled = remoteWins("api.enabled") ? config.api.enabled - : envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues); + : yield* envOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); const apiTlsCertPath = remoteWins("api.tls.cert_path") ? config.api.tls.cert_path - : envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); + : yield* envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); const apiTlsKeyPath = remoteWins("api.tls.key_path") ? config.api.tls.key_path - : envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); + : yield* envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); if (apiEnabled && apiTlsEnabled) { - readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + yield* readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } // `api.port === 0` is rejected only when `api.enabled`, unlike `db.port` below. Resolved once // so the check and the URL derivation below share the same overridden value. const apiPort = remoteWins("api.port") ? config.api.port - : envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); + : yield* envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); const apiExternalUrl = resolveApiExternalUrl( { external_url: remoteWins("api.external_url") ? config.api.external_url - : envOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), + : yield* envOverride( + "SUPABASE_API_EXTERNAL_URL", + config.api.external_url, + projectEnvValues, + ), port: apiPort, tls: { enabled: apiTlsEnabled }, }, @@ -2448,19 +2571,19 @@ export function resolveLocalConfigValues( // it's unconditionally required, and a decoded `0` fails validation. const dbPort = remoteWins("db.port") ? config.db.port - : envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + : yield* envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); // Validate-only: the shadow's own resolved `majorVersion` comes from // `resolveDbBootstrapConfig`, which gates it separately. const majorVersion = remoteWins("db.major_version") ? config.db.major_version - : envOverrideMajorVersion(config.db.major_version, projectEnvValues); + : yield* envOverrideMajorVersion(config.db.major_version, projectEnvValues); // Validate-only: the actual resolved settings `start` needs are recomputed at their own call // site. - resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); + yield* resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); // Validate-only: `start` doesn't otherwise consume `db.network_restrictions.enabled` (only // `config push` does). if (!remoteWins("db.network_restrictions.enabled")) { - envOverrideBool( + yield* envOverrideBool( "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", config.db.network_restrictions.enabled, "db.network_restrictions.enabled", @@ -2472,24 +2595,24 @@ export function resolveLocalConfigValues( // `/etc/postgresql-custom/pgsodium_root.key` on every start. const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; if (rawRootKeyValue !== undefined && typeof rawRootKeyValue !== "string") { - throw new ConfigValidateError( + return yield* new ConfigValidateError( "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", ); } const rawRootKey = remoteWins("db.root_key") ? rawRootKeyValue - : envOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); + : yield* envOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); const rootKey = rawRootKey === undefined || rawRootKey.length === 0 ? POSTGRES_DEFAULT_ROOT_KEY - : (decryptAuthSecret(rawRootKey, projectEnvValues) ?? POSTGRES_DEFAULT_ROOT_KEY); + : ((yield* decryptAuthSecret(rawRootKey, projectEnvValues)) ?? POSTGRES_DEFAULT_ROOT_KEY); const storageBucketNames = config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; // `studio.port === 0` is rejected only when `studio.enabled`, same enabled-gated pattern as // `api.port` above. const studioEnabled = remoteWins("studio.enabled") ? config.studio.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_STUDIO_ENABLED", config.studio.enabled, "studio.enabled", @@ -2497,19 +2620,24 @@ export function resolveLocalConfigValues( ); const studioPort = remoteWins("studio.port") ? config.studio.port - : envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues); - // `envOverride` itself never throws, but `studio.api_url` feeds `validateResolvedConfig`'s + : yield* envOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); + // `envOverride` itself never fails, but `studio.api_url` feeds `validateResolvedConfig`'s // URL-parse check below, which does throw on a malformed URL, so this is gated the same way // as `studio.enabled`/`studio.port` above. const studioApiUrl = remoteWins("studio.api_url") ? config.studio.api_url - : (envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + : ((yield* envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues)) ?? config.studio.api_url); // `local_smtp.port === 0` is rejected only when `local_smtp.enabled`; the deprecated // `inbucket.enabled` alias is the same underlying flag, not a second one. const mailpitEnabled = remoteWins("local_smtp.enabled") ? config.local_smtp.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", config.local_smtp.enabled, "local_smtp.enabled", @@ -2517,23 +2645,23 @@ export function resolveLocalConfigValues( ); const mailpitPort = remoteWins("local_smtp.port") ? config.local_smtp.port - : envOverridePort( + : yield* envOverridePort( "SUPABASE_LOCAL_SMTP_PORT", config.local_smtp.port, "local_smtp.port", projectEnvValues, ); - const jwtSecret = resolveJwtSecret( - decryptAuthSecret( + const jwtSecret = yield* resolveJwtSecret( + yield* decryptAuthSecret( remoteWins("auth.jwt_secret") ? config.auth.jwt_secret - : envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), + : yield* envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), projectEnvValues, ), ); const signingKeysPath = remoteWins("auth.signing_keys_path") ? config.auth.signing_keys_path - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_SIGNING_KEYS_PATH", config.auth.signing_keys_path, projectEnvValues, @@ -2543,7 +2671,7 @@ export function resolveLocalConfigValues( // and anon/service_role key generation run unconditionally either way. const authEnabled = remoteWins("auth.enabled") ? config.auth.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, "auth.enabled", @@ -2554,14 +2682,14 @@ export function resolveLocalConfigValues( // `db.port === 0` above. const siteUrl = remoteWins("auth.site_url") ? config.auth.site_url - : (envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? + : ((yield* envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues)) ?? config.auth.site_url); const jwtIssuer = remoteWins("auth.jwt_issuer") ? config.auth.jwt_issuer - : envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); + : yield* envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); const jwtExpiry = remoteWins("auth.jwt_expiry") ? config.auth.jwt_expiry - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_JWT_EXPIRY", "auth.jwt_expiry", config.auth.jwt_expiry, @@ -2569,14 +2697,14 @@ export function resolveLocalConfigValues( ); const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") ? undefined - : envOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); + : yield* envOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); const additionalRedirectUrls = additionalRedirectUrlsOverride !== undefined ? additionalRedirectUrlsOverride.split(",") : config.auth.additional_redirect_urls; const enableSignup = remoteWins("auth.enable_signup") ? config.auth.enable_signup - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_SIGNUP", config.auth.enable_signup, "auth.enable_signup", @@ -2584,7 +2712,7 @@ export function resolveLocalConfigValues( ); const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") ? config.auth.enable_anonymous_sign_ins - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", config.auth.enable_anonymous_sign_ins, "auth.enable_anonymous_sign_ins", @@ -2592,7 +2720,7 @@ export function resolveLocalConfigValues( ); const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") ? config.auth.enable_refresh_token_rotation - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", config.auth.enable_refresh_token_rotation, "auth.enable_refresh_token_rotation", @@ -2600,7 +2728,7 @@ export function resolveLocalConfigValues( ); const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") ? config.auth.refresh_token_reuse_interval - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", "auth.refresh_token_reuse_interval", config.auth.refresh_token_reuse_interval, @@ -2608,7 +2736,7 @@ export function resolveLocalConfigValues( ); const enableManualLinking = remoteWins("auth.enable_manual_linking") ? config.auth.enable_manual_linking - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", config.auth.enable_manual_linking, "auth.enable_manual_linking", @@ -2616,7 +2744,7 @@ export function resolveLocalConfigValues( ); const minimumPasswordLength = remoteWins("auth.minimum_password_length") ? config.auth.minimum_password_length - : envOverrideUint( + : yield* envOverrideUint( "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "auth.minimum_password_length", config.auth.minimum_password_length, @@ -2624,9 +2752,12 @@ export function resolveLocalConfigValues( ); const passwordRequirements = remoteWins("auth.password_requirements") ? config.auth.password_requirements - : envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues); + : yield* envOverrideAuthPasswordRequirements( + config.auth.password_requirements, + projectEnvValues, + ); const authDocument = asRecord(document?.["auth"]); - const captchaInput = resolveAuthCaptcha( + const captchaInput = yield* resolveAuthCaptcha( authDocument, config.auth.captcha, projectEnvValues, @@ -2638,9 +2769,12 @@ export function resolveLocalConfigValues( // to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 - ? (resolveConfiguredSigningKeys(config, workdir, projectEnvValues, remoteOverrideKeys) ?? [ - DEFAULT_SIGNING_KEY, - ])[0] + ? ((yield* resolveConfiguredSigningKeys( + config, + workdir, + projectEnvValues, + remoteOverrideKeys, + )) ?? [DEFAULT_SIGNING_KEY])[0] : undefined; // This block only accumulates the inputs passkey/webauthn/hook/mfa/email/smtp/third_party // validation needs; the checks themselves run once, later, in the single @@ -2657,11 +2791,11 @@ export function resolveLocalConfigValues( // config are env-bindable, so an absent `[auth.passkey]`/`[auth.webauthn]` section is never // synthesized from an env override alone. const passkeyEnabled = remoteWins("auth.passkey.enabled") - ? rawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") + ? yield* rawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") : passkeyDoc !== undefined - ? envOverrideBool( + ? yield* envOverrideBool( "SUPABASE_AUTH_PASSKEY_ENABLED", - rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), + yield* rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), "auth.passkey.enabled", projectEnvValues, ) @@ -2671,12 +2805,12 @@ export function resolveLocalConfigValues( const rpId = remoteWins("auth.webauthn.rp_id") ? configuredRpId : webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) + ? yield* envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) : undefined; const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") ? undefined : webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + ? yield* envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) : undefined; // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. const rawRpOrigins = webauthnDoc?.["rp_origins"]; @@ -2693,7 +2827,7 @@ export function resolveLocalConfigValues( : undefined; // Only enabled hooks are forwarded, in {@link HOOK_TYPE_ORDER}. - const resolvedHooks = resolveAuthHooks( + const resolvedHooks = yield* resolveAuthHooks( authDocument, config.auth.hook, projectEnvValues, @@ -2706,7 +2840,11 @@ export function resolveLocalConfigValues( return { type: hookType, uri: resolved.uri, secrets: resolved.secrets }; }); - const resolvedMfa = resolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); + const resolvedMfa = yield* resolveAuthMfa( + config.auth.mfa, + projectEnvValues, + remoteOverrideKeys, + ); const mfa: ReadonlyArray = [ { label: "totp", @@ -2725,12 +2863,21 @@ export function resolveLocalConfigValues( }, ]; - readAuthEmailTemplateContent( - resolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), + yield* readAuthEmailTemplateContent( + yield* resolveAuthEmail( + config.auth.email, + authDocument, + projectEnvValues, + remoteOverrideKeys, + ), workdir, ); - const resolvedSmtp = resolveAuthEmailSmtp(authDocument, projectEnvValues, remoteOverrideKeys); + const resolvedSmtp = yield* resolveAuthEmailSmtp( + authDocument, + projectEnvValues, + remoteOverrideKeys, + ); const smtp: SmtpInput | undefined = resolvedSmtp === undefined ? undefined @@ -2743,7 +2890,7 @@ export function resolveLocalConfigValues( adminEmail: resolvedSmtp.adminEmail, }; - const thirdParty = resolveThirdPartyProviders( + const thirdParty = yield* resolveThirdPartyProviders( config.auth.third_party, projectEnvValues, remoteOverrideKeys, @@ -2764,7 +2911,7 @@ export function resolveLocalConfigValues( // gate, unlike `studio.port`/`local_smtp.port` above. const denoVersion = remoteWins("edge_runtime.deno_version") ? config.edge_runtime.deno_version - : envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + : yield* envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); // When `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP fields are // required. Backend-enum validation is covered at decode time for the config.toml-sourced @@ -2772,34 +2919,34 @@ export function resolveLocalConfigValues( // env-override path — see {@link envOverrideAnalyticsBackend} for that case. const analyticsEnabled = remoteWins("analytics.enabled") ? config.analytics.enabled - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_ANALYTICS_ENABLED", config.analytics.enabled, "analytics.enabled", projectEnvValues, ); - const analyticsBackend = envOverrideAnalyticsBackend( + const analyticsBackend = yield* envOverrideAnalyticsBackend( config.analytics.backend, projectEnvValues, remoteWins("analytics.backend"), ); const gcpProjectId = remoteWins("analytics.gcp_project_id") ? config.analytics.gcp_project_id - : envOverride( + : yield* envOverride( "SUPABASE_ANALYTICS_GCP_PROJECT_ID", config.analytics.gcp_project_id, projectEnvValues, ); const gcpProjectNumber = remoteWins("analytics.gcp_project_number") ? config.analytics.gcp_project_number - : envOverride( + : yield* envOverride( "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", config.analytics.gcp_project_number, projectEnvValues, ); const gcpJwtPath = remoteWins("analytics.gcp_jwt_path") ? config.analytics.gcp_jwt_path - : envOverride( + : yield* envOverride( "SUPABASE_ANALYTICS_GCP_JWT_PATH", config.analytics.gcp_jwt_path, projectEnvValues, @@ -2816,7 +2963,7 @@ export function resolveLocalConfigValues( // `validateResolvedConfig`'s existing JSON-validity check reports it. const webhooksEnabled = remoteWins("experimental.webhooks.enabled") ? config.experimental.webhooks?.enabled === true - : envOverrideBool( + : yield* envOverrideBool( "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", config.experimental.webhooks?.enabled === true, "experimental.webhooks.enabled", @@ -2824,11 +2971,11 @@ export function resolveLocalConfigValues( ); const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") ? (config.experimental.pgdelta?.format_options ?? "") - : (envOverride( + : ((yield* envOverride( "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", config.experimental.pgdelta?.format_options, projectEnvValues, - ) ?? ""); + )) ?? ""); // Every pure validation check runs in one place, here, rather than interleaved with this // function's 3 I/O reads (signing keys, api.tls cert/key, email content) at their original @@ -2872,12 +3019,20 @@ export function resolveLocalConfigValues( analytics: analyticsInput, experimental: experimentalInput, }; - validateResolvedConfig(input); + yield* Effect.try({ + try: () => validateResolvedConfig(input), + catch: (cause) => (cause instanceof ConfigValidateError ? cause : thrownError(cause)), + }); // sms/external run after the shared validation call, since both need the env-override-aware // resolved value rather than the raw decoded config it validates. if (authEnabled) { - validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); - validateAuthExternalProviders( + yield* validateAuthSmsProviders( + authDocument, + config.auth.sms, + projectEnvValues, + remoteOverrideKeys, + ); + yield* validateAuthExternalProviders( authDocument, config.auth.external, projectEnvValues, @@ -2887,9 +3042,9 @@ export function resolveLocalConfigValues( // Decrypted like `auth.email.smtp.pass`/`auth.captcha.secret`. const openaiApiKey = remoteWins("studio.openai_api_key") - ? decryptAuthSecret(config.studio.openai_api_key, projectEnvValues) - : decryptAuthSecret( - envOverride( + ? yield* decryptAuthSecret(config.studio.openai_api_key, projectEnvValues) + : yield* decryptAuthSecret( + yield* envOverride( "SUPABASE_STUDIO_OPENAI_API_KEY", config.studio.openai_api_key, projectEnvValues, @@ -2925,9 +3080,9 @@ export function resolveLocalConfigValues( // Decrypted like `anon_key`/`service_role_key` below. publishableKey: resolveOpaqueKey( remoteWins("auth.publishable_key") - ? decryptAuthSecret(config.auth.publishable_key, projectEnvValues) - : decryptAuthSecret( - envOverride( + ? yield* decryptAuthSecret(config.auth.publishable_key, projectEnvValues) + : yield* decryptAuthSecret( + yield* envOverride( "SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues, @@ -2938,30 +3093,34 @@ export function resolveLocalConfigValues( ), secretKey: resolveOpaqueKey( remoteWins("auth.secret_key") - ? decryptAuthSecret(config.auth.secret_key, projectEnvValues) - : decryptAuthSecret( - envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + ? yield* decryptAuthSecret(config.auth.secret_key, projectEnvValues) + : yield* decryptAuthSecret( + yield* envOverride( + "SUPABASE_AUTH_SECRET_KEY", + config.auth.secret_key, + projectEnvValues, + ), projectEnvValues, ), defaultSecretKey, ), jwtSecret, - anonKey: resolveSignedKey( - decryptAuthSecret( + anonKey: yield* resolveSignedKey( + yield* decryptAuthSecret( remoteWins("auth.anon_key") ? config.auth.anon_key - : envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), + : yield* envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), projectEnvValues, ), jwtSecret, signingKey, "anon", ), - serviceRoleKey: resolveSignedKey( - decryptAuthSecret( + serviceRoleKey: yield* resolveSignedKey( + yield* decryptAuthSecret( remoteWins("auth.service_role_key") ? config.auth.service_role_key - : envOverride( + : yield* envOverride( "SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key, projectEnvValues, @@ -2985,8 +3144,13 @@ export function resolveLocalConfigValues( // presence-only and must see the raw value to reject an explicit `project_id = ""`. projectId: sanitizeProjectId(resolvedProjectId ?? ""), edgeRuntimeDenoVersion: denoVersion, - }; -} + } satisfies LocalConfigValues; +}); + +const asConfigValidateError = (cause: unknown) => + cause instanceof ConfigValidateError + ? cause + : new ConfigValidateError(cause instanceof Error ? cause.message : String(cause)); /** Resolves local and remote signing keys for services that consume the local JWKS document. */ export const resolveLocalJwks = Effect.fnUntraced(function* ( @@ -2996,158 +3160,156 @@ export const resolveLocalJwks = Effect.fnUntraced(function* ( projectEnvValues?: Readonly>, remoteOverrideKeys: ReadonlySet = new Set(), ) { - const { issuerUrl, signingKeys, signingKeysPath } = yield* Effect.try({ - try: () => { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - // Every resolved config carries the default ES256 key, regardless of `auth.enabled`. It's - // only ever replaced by a configured `signing_keys_path` file, and only when that file is - // actually read (gated on `auth.enabled` — see {@link resolveConfiguredSigningKeys}). So JWKS - // resolution always publishes either the file's keys or this default, never neither — - // `GOTRUE_JWT_KEYS` signs with the same default, so the two must never disagree. - const signingKeys: ReadonlyArray = resolveConfiguredSigningKeys( - config, - workdir, - projectEnvValues, - remoteOverrideKeys, - ) ?? [DEFAULT_SIGNING_KEY]; - - // Built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's - // `tenant_region`) rather than the validation-only `ThirdPartyInput`, since - // {@link resolveThirdPartyIssuerUrl} needs the full set to build the issuer URL. - const thirdParty: ThirdPartyProvidersLike = { - firebase: { - enabled: remoteWins("auth.third_party.firebase.enabled") - ? config.auth.third_party.firebase.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ), - project_id: remoteWins("auth.third_party.firebase.project_id") - ? config.auth.third_party.firebase.project_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - projectEnvValues, - ), - }, - auth0: { - enabled: remoteWins("auth.third_party.auth0.enabled") - ? config.auth.third_party.auth0.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ), - tenant: remoteWins("auth.third_party.auth0.tenant") - ? config.auth.third_party.auth0.tenant - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - projectEnvValues, - ), - tenant_region: remoteWins("auth.third_party.auth0.tenant_region") - ? config.auth.third_party.auth0.tenant_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - projectEnvValues, - ), - }, - aws_cognito: { - enabled: remoteWins("auth.third_party.aws_cognito.enabled") - ? config.auth.third_party.aws_cognito.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ), - user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") - ? config.auth.third_party.aws_cognito.user_pool_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - projectEnvValues, - ), - user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") - ? config.auth.third_party.aws_cognito.user_pool_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - projectEnvValues, - ), - }, - clerk: { - enabled: remoteWins("auth.third_party.clerk.enabled") - ? config.auth.third_party.clerk.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ), - domain: remoteWins("auth.third_party.clerk.domain") - ? config.auth.third_party.clerk.domain - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - projectEnvValues, - ), - }, - workos: { - enabled: remoteWins("auth.third_party.workos.enabled") - ? config.auth.third_party.workos.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ), - issuer_url: remoteWins("auth.third_party.workos.issuer_url") - ? config.auth.third_party.workos.issuer_url - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - projectEnvValues, - ), - }, - }; - - // This function is called unconditionally, regardless of `auth.enabled`, but - // `resolveThirdPartyIssuerUrl`'s "at most one enabled" + required-field checks are only - // meaningful while auth is enabled — `resolveLocalConfigValues` already ran the equivalent - // check in that case. When auth is disabled, that validation is (correctly) skipped, so this - // uses the unchecked, no-throw issuer-url builder instead. - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const issuerUrl = Option.fromNullishOr( - authEnabled - ? resolveThirdPartyIssuerUrl(thirdParty) - : thirdPartyIssuerUrlUnchecked(thirdParty), - ).pipe(Option.filter((value) => value.length > 0)); - return { issuerUrl, signingKeys, signingKeysPath }; - }, - catch: (cause) => - cause instanceof ConfigValidateError - ? cause - : new ConfigValidateError(cause instanceof Error ? cause.message : String(cause)), - }); + const { issuerUrl, signingKeys, signingKeysPath } = yield* Effect.gen(function* () { + const remoteWins = makeRemoteWins(remoteOverrideKeys); + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : yield* envOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); + // Every resolved config carries the default ES256 key, regardless of `auth.enabled`. It's + // only ever replaced by a configured `signing_keys_path` file, and only when that file is + // actually read (gated on `auth.enabled` — see {@link resolveConfiguredSigningKeys}). So JWKS + // resolution always publishes either the file's keys or this default, never neither — + // `GOTRUE_JWT_KEYS` signs with the same default, so the two must never disagree. + const signingKeys: ReadonlyArray = (yield* resolveConfiguredSigningKeys( + config, + workdir, + projectEnvValues, + remoteOverrideKeys, + )) ?? [DEFAULT_SIGNING_KEY]; + + // Built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's + // `tenant_region`) rather than the validation-only `ThirdPartyInput`, since + // {@link resolveThirdPartyIssuerUrl} needs the full set to build the issuer URL. + const thirdParty: ThirdPartyProvidersLike = { + firebase: { + enabled: remoteWins("auth.third_party.firebase.enabled") + ? config.auth.third_party.firebase.enabled + : yield* envOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + config.auth.third_party.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ), + project_id: remoteWins("auth.third_party.firebase.project_id") + ? config.auth.third_party.firebase.project_id + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + config.auth.third_party.firebase.project_id, + projectEnvValues, + ), + }, + auth0: { + enabled: remoteWins("auth.third_party.auth0.enabled") + ? config.auth.third_party.auth0.enabled + : yield* envOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + config.auth.third_party.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ), + tenant: remoteWins("auth.third_party.auth0.tenant") + ? config.auth.third_party.auth0.tenant + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + config.auth.third_party.auth0.tenant, + projectEnvValues, + ), + tenant_region: remoteWins("auth.third_party.auth0.tenant_region") + ? config.auth.third_party.auth0.tenant_region + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", + config.auth.third_party.auth0.tenant_region, + projectEnvValues, + ), + }, + aws_cognito: { + enabled: remoteWins("auth.third_party.aws_cognito.enabled") + ? config.auth.third_party.aws_cognito.enabled + : yield* envOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + config.auth.third_party.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ), + user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") + ? config.auth.third_party.aws_cognito.user_pool_id + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + config.auth.third_party.aws_cognito.user_pool_id, + projectEnvValues, + ), + user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") + ? config.auth.third_party.aws_cognito.user_pool_region + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + config.auth.third_party.aws_cognito.user_pool_region, + projectEnvValues, + ), + }, + clerk: { + enabled: remoteWins("auth.third_party.clerk.enabled") + ? config.auth.third_party.clerk.enabled + : yield* envOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + config.auth.third_party.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ), + domain: remoteWins("auth.third_party.clerk.domain") + ? config.auth.third_party.clerk.domain + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + config.auth.third_party.clerk.domain, + projectEnvValues, + ), + }, + workos: { + enabled: remoteWins("auth.third_party.workos.enabled") + ? config.auth.third_party.workos.enabled + : yield* envOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + config.auth.third_party.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ), + issuer_url: remoteWins("auth.third_party.workos.issuer_url") + ? config.auth.third_party.workos.issuer_url + : yield* envOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + config.auth.third_party.workos.issuer_url, + projectEnvValues, + ), + }, + }; + + // This function is called unconditionally, regardless of `auth.enabled`, but + // `resolveThirdPartyIssuerUrl`'s "at most one enabled" + required-field checks are only + // meaningful while auth is enabled — `resolveLocalConfigValues` already ran the equivalent + // check in that case. When auth is disabled, that validation is (correctly) skipped, so this + // uses the unchecked, no-throw issuer-url builder instead. + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : yield* envOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const issuerUrl = Option.fromNullishOr( + yield* Effect.try({ + try: () => + authEnabled + ? resolveThirdPartyIssuerUrl(thirdParty) + : thirdPartyIssuerUrlUnchecked(thirdParty), + catch: asConfigValidateError, + }), + ).pipe(Option.filter((value) => value.length > 0)); + return { issuerUrl, signingKeys, signingKeysPath }; + }).pipe(Effect.mapError(asConfigValidateError)); const keys: unknown[] = []; if (Option.isSome(issuerUrl)) { keys.push( diff --git a/apps/cli/src/command-internal/local-config-values.unit.test.ts b/apps/cli/src/command-internal/local-config-values.unit.test.ts index 75fcdbb769..126270f897 100644 --- a/apps/cli/src/command-internal/local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/local-config-values.unit.test.ts @@ -1,14 +1,13 @@ import { generateKeyPairSync } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Effect, Schema } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; import { importJWK, jwtVerify } from "jose"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { useTempWorkdir, withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { DEFAULT_SIGNING_KEY } from "./go-jwt.ts"; import { POSTGRES_DEFAULT_ROOT_KEY, @@ -43,14 +42,31 @@ import { resolveLocalJwks, } from "./local-config-values.ts"; +/** The `SUPABASE_*` overrides a test pins: the only env its `Config` reads see. */ +const testEnv: Record = {}; + +const testLayer = Layer.mergeAll( + BunServices.layer, + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord(testEnv, { preserveEmptyStrings: true }), + ), +); + +const run = (effect: Effect.Effect) => + Effect.runSync( + withEnvVar("DOTENV_PRIVATE_KEY", undefined, effect).pipe(Effect.provide(testLayer)), + ); + const runLocalJwks = (...args: Parameters) => - Effect.runPromise( - resolveLocalJwks(...args).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), - ), + resolveLocalJwks(...args).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), ); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); const WORKDIR = "/tmp/local-config-values-test"; @@ -65,16 +81,21 @@ function generateRsaJwk(): Record { return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; } -function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); -} +const writeSigningKeys = Effect.fnUntraced(function* ( + workdir: string, + jwks: ReadonlyArray>, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "signing_keys.json"), yield* encodeJson(jwks)); +}); describe("resolveLocalConfigValues", () => { it("derives every URL from api.external_url when unset", () => { const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("http://127.0.0.1:54321"); expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); @@ -88,32 +109,32 @@ describe("resolveLocalConfigValues", () => { it("uses https and the configured port when api.tls.enabled", () => { const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("https://127.0.0.1:54321"); }); it("uses api.external_url verbatim when configured", () => { const config = baseConfig({ api: { external_url: "https://example.test" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("https://example.test"); expect(values.restUrl).toBe("https://example.test/rest/v1"); }); it("brackets an IPv6 hostname when building host:port", () => { const config = baseConfig(); - const values = resolveLocalConfigValues(config, "::1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "::1", WORKDIR)); expect(values.apiUrl).toBe("http://[::1]:54321"); }); it("builds the db URL with the hardcoded postgres password", () => { const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); }); it("falls back to the default JWT secret and opaque keys when unset", () => { const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); @@ -123,14 +144,14 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig({ auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.publishableKey).toBe("sb_publishable_custom"); expect(values.secretKey).toBe("sb_secret_custom"); }); it("signs the default anon/service_role JWTs from the resolved secret", () => { const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); const [, anonPayload] = values.anonKey.split("."); const [, serviceRolePayload] = values.serviceRoleKey.split("."); expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ @@ -145,21 +166,21 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig({ auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.anonKey).toBe("configured-anon"); expect(values.serviceRoleKey).toBe("configured-service-role"); }); it("signs anon/service_role JWTs from a configured jwt_secret", () => { const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.jwtSecret).toBe("a".repeat(32)); expect(values.anonKey).not.toBe(""); }); it("rejects a configured jwt_secret shorter than 16 characters", () => { const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidJwtSecretError, ); }); @@ -170,54 +191,51 @@ describe("resolveLocalConfigValues", () => { const VAULT_ENCRYPTED = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; - afterEach(() => { - delete process.env["DOTENV_PRIVATE_KEY"]; - }); + const withVaultKey = (effect: Effect.Effect) => + withEnvVar("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY, effect); it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidJwtSecretError, - ); + expect(() => + run(withVaultKey(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))), + ).toThrow(InvalidJwtSecretError); }); it("decrypts an encrypted: publishable_key when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(withVaultKey(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))); expect(values.publishableKey).toBe("value"); }); it("fails config loading for an encrypted: secret with no private key, matching Go", () => { const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "failed to parse config: missing private key", ); }); it("decrypts an encrypted: auth.email.smtp.pass, matching Go's Secret-typed Smtp.Pass field", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const document = { auth: { email: { smtp: { enabled: true, pass: VAULT_ENCRYPTED } } } }; - const resolved = resolveAuthEmailSmtp(document.auth, undefined); + const resolved = run(withVaultKey(resolveAuthEmailSmtp(document.auth, undefined))); expect(resolved?.pass).toBe("value"); }); it("decrypts an encrypted: studio.openai_api_key, matching Go's Secret-typed OpenaiApiKey field", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig({ studio: { openai_api_key: VAULT_ENCRYPTED } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(withVaultKey(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))); expect(values.openaiApiKey).toBe("value"); }); it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, - }); + const values = run( + withVaultKey( + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, + }), + ), + ); expect(values.secretKey).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; }); }); @@ -225,46 +243,50 @@ describe("resolveLocalConfigValues", () => { // An explicit `project_id = ""` overwrites the workdir-basename default with the literal // empty string, unlike an absent key. const config = baseConfig({ project_id: "" }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: project_id", ); }); it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + const config = Schema.decodeSync(CliConfigSchema)({}); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { // The workdir-basename default still applies with no `project_id` key present, so a workdir // whose basename sanitizes to empty (e.g. `!!!`) still fails validation. - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( + const config = Schema.decodeSync(CliConfigSchema)({}); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!"))).toThrow( "Missing required field in config: project_id", ); }); it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); + const config = Schema.decodeSync(CliConfigSchema)({}); expect(() => - resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { - SUPABASE_PROJECT_ID: "env-project", - }), + run( + resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { + SUPABASE_PROJECT_ID: "env-project", + }), + ), ).not.toThrow(); }); it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { const config = baseConfig({ project_id: "" }); expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_PROJECT_ID: "env-project", - }), + run( + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_PROJECT_ID: "env-project", + }), + ), ).not.toThrow(); }); it("hardcodes the Go-parity local S3 credentials", () => { const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); expect(values.storageS3SecretAccessKey).toBe( "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", @@ -285,80 +307,91 @@ describe("resolveLocalConfigValues", () => { ] as const; afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; + for (const key of ENV_KEYS) delete testEnv[key]; }); it("overrides jwt_secret even when config.toml sets one", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + testEnv["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.jwtSecret).toBe("b".repeat(32)); }); it("overrides publishable_key/secret_key", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; + testEnv["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; + testEnv["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; const config = baseConfig({ auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.publishableKey).toBe("env-publishable"); expect(values.secretKey).toBe("env-secret"); }); it("overrides anon_key/service_role_key", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; + testEnv["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; + testEnv["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; const config = baseConfig({ auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.anonKey).toBe("env-anon"); expect(values.serviceRoleKey).toBe("env-service-role"); }); it("treats an empty env var as unset, matching Viper's default", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; + testEnv["SUPABASE_AUTH_JWT_SECRET"] = ""; const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.jwtSecret).toBe("a".repeat(32)); }); it("still applies the short-secret validation to an env-provided jwt_secret", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; + testEnv["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidJwtSecretError, ); }); - it("overrides signing_keys_path even when config.toml doesn't set one", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - - const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; - const publicKey = await importJWK(publicJwk, "RS256"); - const { protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - }); - - it("prefers an env-provided signing_keys_path over config.toml's", () => { - const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; - const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; - writeSigningKeys(tempRoot.current, [envJwk]); - const supabaseDir = join(tempRoot.current, "supabase"); - writeFileSync(join(supabaseDir, "other_keys.json"), JSON.stringify([configJwk])); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; - const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - const [header] = values.anonKey.split("."); - expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ - kid: "env-kid", - }); - }); + it.live("overrides signing_keys_path even when config.toml doesn't set one", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = yield* Effect.promise(() => importJWK(publicJwk, "RS256")); + const { protectedHeader } = yield* Effect.promise(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + }).pipe(Effect.provide(testLayer)), + ); + + it.live("prefers an env-provided signing_keys_path over config.toml's", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; + const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; + yield* writeSigningKeys(tempRoot.current, [envJwk]); + const supabaseDir = path.join(tempRoot.current, "supabase"); + yield* fs.writeFileString( + path.join(supabaseDir, "other_keys.json"), + yield* encodeJson([configJwk]), + ); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [header] = values.anonKey.split("."); + expect(yield* decodeJson(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ + kid: "env-kid", + }); + }).pipe(Effect.provide(testLayer)), + ); }); describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { @@ -367,41 +400,41 @@ describe("resolveLocalConfigValues", () => { const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - delete process.env["INDIRECT_JWT_SECRET"]; - delete process.env["INDIRECT_DB_PORT"]; - delete process.env["INDIRECT_API_ENABLED"]; + for (const key of ENV_KEYS) delete testEnv[key]; + delete testEnv["INDIRECT_JWT_SECRET"]; + delete testEnv["INDIRECT_DB_PORT"]; + delete testEnv["INDIRECT_API_ENABLED"]; }); it("resolves a string override's env(VAR) indirection", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; - process.env["INDIRECT_JWT_SECRET"] = "c".repeat(32); + testEnv["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + testEnv["INDIRECT_JWT_SECRET"] = "c".repeat(32); const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.jwtSecret).toBe("c".repeat(32)); }); it("resolves a port override's env(VAR) indirection", () => { - process.env["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; - process.env["INDIRECT_DB_PORT"] = "54329"; + testEnv["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; + testEnv["INDIRECT_DB_PORT"] = "54329"; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); }); it("resolves a bool override's env(VAR) indirection", () => { - process.env["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; - process.env["INDIRECT_API_ENABLED"] = "false"; + testEnv["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; + testEnv["INDIRECT_API_ENABLED"] = "false"; const config = baseConfig({ api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + testEnv["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); }); }); @@ -417,50 +450,50 @@ describe("resolveLocalConfigValues", () => { ] as const; afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; + for (const key of ENV_KEYS) delete testEnv[key]; }); it("overrides db.port for the derived DB URL and the exposed dbPort", () => { - process.env["SUPABASE_DB_PORT"] = "54329"; + testEnv["SUPABASE_DB_PORT"] = "54329"; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); expect(values.dbPort).toBe(54329); }); it("overrides studio.port for the derived Studio URL", () => { - process.env["SUPABASE_STUDIO_PORT"] = "54330"; + testEnv["SUPABASE_STUDIO_PORT"] = "54330"; const config = baseConfig({ studio: { port: 54323 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.studioUrl).toBe("http://127.0.0.1:54330"); }); it("overrides local_smtp.port for the derived Mailpit URL", () => { - process.env["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; + testEnv["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; const config = baseConfig({ local_smtp: { port: 54324 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); }); it("overrides api.port for every API-derived URL", () => { - process.env["SUPABASE_API_PORT"] = "54332"; + testEnv["SUPABASE_API_PORT"] = "54332"; const config = baseConfig({ api: { port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("http://127.0.0.1:54332"); expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); }); it("overrides api.external_url even when config.toml sets one", () => { - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; + testEnv["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; const config = baseConfig({ api: { external_url: "https://config.example" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("https://env-override.example"); }); it("treats an empty non-auth env var as unset, matching Viper's default", () => { - process.env["SUPABASE_DB_PORT"] = ""; + testEnv["SUPABASE_DB_PORT"] = ""; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); }); @@ -470,47 +503,47 @@ describe("resolveLocalConfigValues", () => { "SUPABASE_LOCAL_SMTP_PORT", "SUPABASE_API_PORT", ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { - process.env[envKey] = "abc"; + testEnv[envKey] = "abc"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidPortEnvOverrideError, ); }); it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { - process.env["SUPABASE_DB_PORT"] = "99999"; + testEnv["SUPABASE_DB_PORT"] = "99999"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidPortEnvOverrideError, ); }); it("resolves an octal leading-zero SUPABASE_DB_PORT override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_PORT"] = "010"; + testEnv["SUPABASE_DB_PORT"] = "010"; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbPort).toBe(8); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:8/postgres"); }); it("resolves a 0x-prefixed SUPABASE_DB_PORT override as hex", () => { - process.env["SUPABASE_DB_PORT"] = "0x1F90"; + testEnv["SUPABASE_DB_PORT"] = "0x1F90"; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbPort).toBe(8080); }); it("still resolves a plain decimal SUPABASE_DB_PORT override with no leading zero", () => { - process.env["SUPABASE_DB_PORT"] = "5432"; + testEnv["SUPABASE_DB_PORT"] = "5432"; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.dbPort).toBe(5432); }); it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the uint16 range", () => { - process.env["SUPABASE_DB_PORT"] = "0x1FFFF"; + testEnv["SUPABASE_DB_PORT"] = "0x1FFFF"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidPortEnvOverrideError, ); }); @@ -518,9 +551,9 @@ describe("resolveLocalConfigValues", () => { // Unlike the malformed/out-of-range cases above, db.port=0 is a required-field failure with // no `enabled` gate, unlike api.port/studio.port/local_smtp.port. it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { - process.env["SUPABASE_DB_PORT"] = "0"; + testEnv["SUPABASE_DB_PORT"] = "0"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: db.port", ); }); @@ -529,67 +562,67 @@ describe("resolveLocalConfigValues", () => { // `api.enabled = true`. it("rejects a configured api.port of 0 when api is enabled", () => { const config = baseConfig({ api: { port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: api.port", ); }); it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { - process.env["SUPABASE_API_PORT"] = "0"; + testEnv["SUPABASE_API_PORT"] = "0"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: api.port", ); }); it("does not reject a zero api.port when api is disabled", () => { const config = baseConfig({ api: { enabled: false, port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); // studio.enabled defaults to true, so this rejection applies without an explicit // `studio.enabled = true`. it("rejects a configured studio.port of 0 when studio is enabled", () => { const config = baseConfig({ studio: { port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: studio.port", ); }); it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { - process.env["SUPABASE_STUDIO_PORT"] = "0"; + testEnv["SUPABASE_STUDIO_PORT"] = "0"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: studio.port", ); }); it("does not reject a zero studio.port when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false, port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { const config = baseConfig({ studio: { api_url: "http://[::1" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, ); }); it("does not reject a malformed studio.api_url when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("does not throw for the default studio.api_url", () => { const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + testEnv["SUPABASE_STUDIO_API_URL"] = "http://[::1"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, ); }); @@ -598,22 +631,22 @@ describe("resolveLocalConfigValues", () => { // `local_smtp.enabled = true`. it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { const config = baseConfig({ local_smtp: { port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: local_smtp.port", ); }); it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_PORT"] = "0"; + testEnv["SUPABASE_LOCAL_SMTP_PORT"] = "0"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: local_smtp.port", ); }); it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); @@ -621,35 +654,35 @@ describe("resolveLocalConfigValues", () => { // Required-field/range assertions live in config-validate.unit.test.ts; only env-override // mechanics are tested here. afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; + delete testEnv["SUPABASE_DB_MAJOR_VERSION"]; }); it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "15"; const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "16"; const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Failed reading config: Invalid db.major_version: 16.", ); }); it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "abc"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Failed reading config: Invalid db.major_version: abc.", ); }); it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; + testEnv["SUPABASE_DB_MAJOR_VERSION"] = ""; const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); @@ -657,22 +690,22 @@ describe("resolveLocalConfigValues", () => { // Always validated eagerly, with no presence/enabled gate — same bucket as // db.port/db.major_version above. afterEach(() => { - delete process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"]; + delete testEnv["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"]; }); it("does not throw for a valid SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "true"; + testEnv["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects a malformed SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "notabool"; + testEnv["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "notabool"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidBoolEnvOverrideError, ); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for db.network_restrictions.enabled: cannot parse "notabool" as a bool', ); }); @@ -681,14 +714,16 @@ describe("resolveLocalConfigValues", () => { describe("db.root_key (unmodeled raw-document field)", () => { it("falls back to the default root key when absent", () => { const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.rootKey).toBe(POSTGRES_DEFAULT_ROOT_KEY); }); it("uses a configured string root_key verbatim", () => { const config = baseConfig(); const document = { db: { root_key: "custom-root-key" } }; - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document); + const values = run( + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ); expect(values.rootKey).toBe("custom-root-key"); }); @@ -696,7 +731,7 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig(); const document = { db: { root_key: 12345 } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow( "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", ); @@ -711,37 +746,37 @@ describe("resolveLocalConfigValues", () => { // Required-field/range assertions live in config-validate.unit.test.ts; only env-override // mechanics are tested here. afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + delete testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; }); it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; + testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: edge_runtime.deno_version", ); }); it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; + testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Failed reading config: Invalid edge_runtime.deno_version: 3.", ); }); it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Failed reading config: Invalid edge_runtime.deno_version: abc.", ); }); it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; + testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); @@ -749,44 +784,44 @@ describe("resolveLocalConfigValues", () => { // Required-field/range assertions live in config-validate.unit.test.ts; only env-override // mechanics are tested here. afterEach(() => { - delete process.env["SUPABASE_ANALYTICS_ENABLED"]; - delete process.env["SUPABASE_ANALYTICS_BACKEND"]; - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + delete testEnv["SUPABASE_ANALYTICS_ENABLED"]; + delete testEnv["SUPABASE_ANALYTICS_BACKEND"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; }); it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; + testEnv["SUPABASE_ANALYTICS_ENABLED"] = "true"; const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: analytics.gcp_project_id", ); }); it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; + testEnv["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: analytics.gcp_project_id", ); }); it("accepts env-provided GCP fields overriding empty config.toml values", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; + testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; + testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; + testEnv["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; + testEnv["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidAnalyticsBackendEnvOverrideError, ); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', ); }); @@ -797,66 +832,68 @@ describe("resolveLocalConfigValues", () => { // required-field/enabled checks themselves live in config-validate.unit.test.ts. it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { const config = baseConfig({ experimental: { webhooks: {} } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); // Required-field/range assertions live in config-validate.unit.test.ts; only env-override // mechanics are tested here. afterEach(() => { - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"]; + delete testEnv["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; + delete testEnv["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"]; }); it("enables webhooks purely via SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when the section omits enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "true"; + testEnv["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "true"; const config = baseConfig({ experimental: { webhooks: {} } }); const document = { experimental: { webhooks: {} } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("rejects a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED override on an already-enabled section", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "notabool"; + testEnv["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "notabool"; const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); const document = { experimental: { webhooks: { enabled: true } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow(InvalidBoolEnvOverrideError); expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow( 'Invalid config for experimental.webhooks.enabled: cannot parse "notabool" as a bool', ); }); it("rejects an invalid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; + testEnv["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Invalid config for experimental.pgdelta.format_options: must be valid JSON", ); }); it("accepts a valid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = '{"keywordCase":"upper"}'; + testEnv["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = '{"keywordCase":"upper"}'; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; + testEnv["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; const config = baseConfig({ experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.pgdelta.format_options"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.pgdelta.format_options"]), + ), ), ).not.toThrow(); }); @@ -866,66 +903,66 @@ describe("resolveLocalConfigValues", () => { // Applied before the default `api.external_url` scheme is derived, so it can flip http/https // even when config.toml disagrees. afterEach(() => { - delete process.env["SUPABASE_API_TLS_ENABLED"]; + delete testEnv["SUPABASE_API_TLS_ENABLED"]; }); it("overrides api.tls.enabled from false to true", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + testEnv["SUPABASE_API_TLS_ENABLED"] = "true"; const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("https://127.0.0.1:54321"); }); it("overrides api.tls.enabled from true to false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "false"; + testEnv["SUPABASE_API_TLS_ENABLED"] = "false"; const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("http://127.0.0.1:54321"); }); it("does not override api.tls.enabled once api.external_url is set", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + testEnv["SUPABASE_API_TLS_ENABLED"] = "true"; const config = baseConfig({ api: { external_url: "http://config.example" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("http://config.example"); }); it("rejects a malformed override instead of falling back to the configured value", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidBoolEnvOverrideError, ); }); it("treats an empty override as unset, matching Viper's default", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = ""; + testEnv["SUPABASE_API_TLS_ENABLED"] = ""; const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.apiUrl).toBe("https://127.0.0.1:54321"); }); }); describe("envOverrideRealtimeIpVersion", () => { afterEach(() => { - delete process.env["SUPABASE_REALTIME_IP_VERSION"]; + delete testEnv["SUPABASE_REALTIME_IP_VERSION"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv4"); + expect(run(envOverrideRealtimeIpVersion("IPv4", undefined))).toBe("IPv4"); }); it("overrides IPv4 to IPv6 via the env var", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv6"; - expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv6"); + testEnv["SUPABASE_REALTIME_IP_VERSION"] = "IPv6"; + expect(run(envOverrideRealtimeIpVersion("IPv4", undefined))).toBe("IPv6"); }); it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; - expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( + testEnv["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; + expect(() => run(envOverrideRealtimeIpVersion("IPv4", undefined))).toThrow( InvalidRealtimeIpVersionEnvOverrideError, ); - expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( + expect(() => run(envOverrideRealtimeIpVersion("IPv4", undefined))).toThrow( 'Invalid config for realtime.ip_version: cannot parse "IPv5" as one of "IPv4", "IPv6"', ); }); @@ -933,41 +970,43 @@ describe("resolveLocalConfigValues", () => { describe("envOverrideRealtimeMaxHeaderLength", () => { afterEach(() => { - delete process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; + delete testEnv["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(4096); + expect(run(envOverrideRealtimeMaxHeaderLength(4096, undefined))).toBe(4096); }); it("overrides the configured value via the env var", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "8192"; - expect(envOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(8192); + testEnv["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "8192"; + expect(run(envOverrideRealtimeMaxHeaderLength(4096, undefined))).toBe(8192); }); it("also honors a projectEnvValues (dotenv) value", () => { expect( - envOverrideRealtimeMaxHeaderLength(4096, { - SUPABASE_REALTIME_MAX_HEADER_LENGTH: "16384", - }), + run( + envOverrideRealtimeMaxHeaderLength(4096, { + SUPABASE_REALTIME_MAX_HEADER_LENGTH: "16384", + }), + ), ).toBe(16384); }); it("rejects an override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( + testEnv["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; + expect(() => run(envOverrideRealtimeMaxHeaderLength(4096, undefined))).toThrow( "Failed reading config: Invalid realtime.max_header_length: 18446744073709551616.", ); }); it("accepts an override of exactly the uint64 max (2^64-1)", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551615"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).not.toThrow(); + testEnv["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551615"; + expect(() => run(envOverrideRealtimeMaxHeaderLength(4096, undefined))).not.toThrow(); }); it("rejects a hex override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( + testEnv["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; + expect(() => run(envOverrideRealtimeMaxHeaderLength(4096, undefined))).toThrow( "Failed reading config: Invalid realtime.max_header_length: 0x10000000000000000.", ); }); @@ -975,16 +1014,16 @@ describe("resolveLocalConfigValues", () => { describe("envOverrideApiMaxRows", () => { afterEach(() => { - delete process.env["SUPABASE_API_MAX_ROWS"]; + delete testEnv["SUPABASE_API_MAX_ROWS"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideApiMaxRows(1000, undefined)).toBe(1000); + expect(run(envOverrideApiMaxRows(1000, undefined))).toBe(1000); }); it("overrides the configured value via the env var", () => { - process.env["SUPABASE_API_MAX_ROWS"] = "500"; - expect(envOverrideApiMaxRows(1000, undefined)).toBe(500); + testEnv["SUPABASE_API_MAX_ROWS"] = "500"; + expect(run(envOverrideApiMaxRows(1000, undefined))).toBe(500); }); }); @@ -993,48 +1032,48 @@ describe("resolveLocalConfigValues", () => { // reject. describe("envOverrideMajorVersion", () => { afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; + delete testEnv["SUPABASE_DB_MAJOR_VERSION"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideMajorVersion(17, undefined)).toBe(17); + expect(run(envOverrideMajorVersion(17, undefined))).toBe(17); }); it("resolves an octal leading-zero override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "010"; - expect(envOverrideMajorVersion(17, undefined)).toBe(8); + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "010"; + expect(run(envOverrideMajorVersion(17, undefined))).toBe(8); }); it("resolves a 0x-prefixed override as hex", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0x10"; - expect(envOverrideMajorVersion(17, undefined)).toBe(16); + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "0x10"; + expect(run(envOverrideMajorVersion(17, undefined))).toBe(16); }); it("resolves a 0b-prefixed override as binary", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0b101"; - expect(envOverrideMajorVersion(17, undefined)).toBe(5); + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "0b101"; + expect(run(envOverrideMajorVersion(17, undefined))).toBe(5); }); it("still resolves a plain decimal override with no leading zero", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; - expect(envOverrideMajorVersion(17, undefined)).toBe(15); + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "15"; + expect(run(envOverrideMajorVersion(17, undefined))).toBe(15); }); it("permits an underscore digit separator between decimal digits", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "1_000"; - expect(envOverrideMajorVersion(17, undefined)).toBe(1000); + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "1_000"; + expect(run(envOverrideMajorVersion(17, undefined))).toBe(1000); }); it("rejects an invalid octal digit instead of silently falling back to decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "08"; - expect(() => envOverrideMajorVersion(17, undefined)).toThrow( + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "08"; + expect(() => run(envOverrideMajorVersion(17, undefined))).toThrow( "Failed reading config: Invalid db.major_version: 08.", ); }); it("rejects a signed override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "+5"; - expect(() => envOverrideMajorVersion(17, undefined)).toThrow( + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "+5"; + expect(() => run(envOverrideMajorVersion(17, undefined))).toThrow( "Failed reading config: Invalid db.major_version: +5.", ); }); @@ -1042,24 +1081,24 @@ describe("resolveLocalConfigValues", () => { describe("envOverridePoolMode", () => { afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_POOL_MODE"]; + delete testEnv["SUPABASE_DB_POOLER_POOL_MODE"]; }); it("falls back to the configured value when unset", () => { - expect(envOverridePoolMode("transaction", undefined)).toBe("transaction"); + expect(run(envOverridePoolMode("transaction", undefined))).toBe("transaction"); }); it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "session"; - expect(envOverridePoolMode("transaction", undefined)).toBe("session"); + testEnv["SUPABASE_DB_POOLER_POOL_MODE"] = "session"; + expect(run(envOverridePoolMode("transaction", undefined))).toBe("session"); }); it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "invalid"; - expect(() => envOverridePoolMode("transaction", undefined)).toThrow( + testEnv["SUPABASE_DB_POOLER_POOL_MODE"] = "invalid"; + expect(() => run(envOverridePoolMode("transaction", undefined))).toThrow( InvalidPoolModeEnvOverrideError, ); - expect(() => envOverridePoolMode("transaction", undefined)).toThrow( + expect(() => run(envOverridePoolMode("transaction", undefined))).toThrow( 'Invalid config for db.pooler.pool_mode: cannot parse "invalid" as one of "transaction", "session"', ); }); @@ -1067,24 +1106,24 @@ describe("resolveLocalConfigValues", () => { describe("envOverrideEdgeRuntimePolicy", () => { afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; + delete testEnv["SUPABASE_EDGE_RUNTIME_POLICY"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("oneshot"); + expect(run(envOverrideEdgeRuntimePolicy("oneshot", undefined))).toBe("oneshot"); }); it("overrides the configured value via the env var", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "per_worker"; - expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("per_worker"); + testEnv["SUPABASE_EDGE_RUNTIME_POLICY"] = "per_worker"; + expect(run(envOverrideEdgeRuntimePolicy("oneshot", undefined))).toBe("per_worker"); }); it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "invalid"; - expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( + testEnv["SUPABASE_EDGE_RUNTIME_POLICY"] = "invalid"; + expect(() => run(envOverrideEdgeRuntimePolicy("oneshot", undefined))).toThrow( InvalidEdgeRuntimePolicyEnvOverrideError, ); - expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( + expect(() => run(envOverrideEdgeRuntimePolicy("oneshot", undefined))).toThrow( 'Invalid config for edge_runtime.policy: cannot parse "invalid" as one of "per_worker", "oneshot"', ); }); @@ -1092,154 +1131,169 @@ describe("resolveLocalConfigValues", () => { describe("envOverrideDefaultPoolSize", () => { afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"]; + delete testEnv["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideDefaultPoolSize(20, undefined)).toBe(20); + expect(run(envOverrideDefaultPoolSize(20, undefined))).toBe(20); }); it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"] = "40"; - expect(envOverrideDefaultPoolSize(20, undefined)).toBe(40); + testEnv["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"] = "40"; + expect(run(envOverrideDefaultPoolSize(20, undefined))).toBe(40); }); }); describe("envOverrideMaxClientConn", () => { afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"]; + delete testEnv["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"]; }); it("falls back to the configured value when unset", () => { - expect(envOverrideMaxClientConn(100, undefined)).toBe(100); + expect(run(envOverrideMaxClientConn(100, undefined))).toBe(100); }); it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"] = "200"; - expect(envOverrideMaxClientConn(100, undefined)).toBe(200); + testEnv["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"] = "200"; + expect(run(envOverrideMaxClientConn(100, undefined))).toBe(200); }); }); describe("resolveAuthCaptcha", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; + delete testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"]; + delete testEnv["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; + delete testEnv["SUPABASE_AUTH_CAPTCHA_SECRET"]; }); it("returns undefined when captcha is not configured", () => { - expect(resolveAuthCaptcha(undefined, undefined, undefined)).toBeUndefined(); + expect(run(resolveAuthCaptcha(undefined, undefined, undefined))).toBeUndefined(); }); it("overrides enabled/provider when the section is present in the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; const authDocument = { captcha: { enabled: false, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, + const resolved = run( + resolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + ), ); expect(resolved?.enabled).toBe(true); expect(resolved?.provider).toBe("turnstile"); }); it("does not apply an env override when [auth.captcha] is absent from the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const resolved = resolveAuthCaptcha( - {}, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + const resolved = run( + resolveAuthCaptcha({}, { enabled: false, provider: "hcaptcha", secret: "shh" }, undefined), ); expect(resolved?.enabled).toBe(false); }); it("decrypts an encrypted: captcha secret", () => { - process.env["DOTENV_PRIVATE_KEY"] = - "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const authDocument = { captcha: { enabled: true } }; - const resolved = resolveAuthCaptcha( - authDocument, - { - enabled: true, - provider: "hcaptcha", - secret: - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/", - }, - undefined, + const resolved = run( + withEnvVar( + "DOTENV_PRIVATE_KEY", + "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb", + resolveAuthCaptcha( + authDocument, + { + enabled: true, + provider: "hcaptcha", + secret: + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/", + }, + undefined, + ), + ), ); expect(resolved?.secret).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; }); it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; const authDocument = { captcha: { enabled: false } }; expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.enabled"]), + run( + resolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + new Set(["auth.captcha.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; const authDocument = { captcha: { enabled: false } }; expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, + run( + resolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + ), ), ).toThrow('cannot parse "not-a-bool" as a bool'); }); it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; const authDocument = { captcha: { enabled: true } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - new Set(["auth.captcha.secret"]), + const resolved = run( + resolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + undefined, + new Set(["auth.captcha.secret"]), + ), ); expect(resolved?.secret).toBe("remote-secret"); }); it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; const authDocument = { captcha: { enabled: true } }; expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, + run( + resolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + undefined, + ), ), ).toThrow("failed to parse config: missing private key"); }); it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; + testEnv["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.provider"]), + const resolved = run( + resolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + undefined, + new Set(["auth.captcha.provider"]), + ), ); expect(resolved?.provider).toBe("hcaptcha"); }); it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; + testEnv["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, + const resolved = run( + resolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + undefined, + ), ); expect(resolved?.provider).toBe("turnstile"); }); @@ -1247,7 +1301,7 @@ describe("resolveLocalConfigValues", () => { describe("resolveAuthEmail", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; + delete testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; }); it("keeps an explicit empty subject present in the raw document, not omitted", () => { @@ -1255,7 +1309,7 @@ describe("resolveLocalConfigValues", () => { auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, }); const authDocument = { email: { template: { confirmation: { subject: "" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); + const resolved = run(resolveAuthEmail(config.auth.email, authDocument, undefined)); expect(resolved.template["confirmation"]?.subject).toBe(""); }); @@ -1264,41 +1318,43 @@ describe("resolveLocalConfigValues", () => { auth: { email: { template: { confirmation: { content_path: "x" } } } }, }); const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); + const resolved = run(resolveAuthEmail(config.auth.email, authDocument, undefined)); expect(resolved.template["confirmation"]?.subject).toBeUndefined(); }); it("prefers an env-overridden subject over the raw document's presence, even when absent", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = "Overridden subject"; + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = "Overridden subject"; const config = baseConfig({ auth: { email: { template: { confirmation: { content_path: "x" } } } }, }); const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); + const resolved = run(resolveAuthEmail(config.auth.email, authDocument, undefined)); expect(resolved.template["confirmation"]?.subject).toBe("Overridden subject"); }); describe("max_frequency — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"]; + delete testEnv["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"]; }); it("prefers a remote-set auth.email.max_frequency over a conflicting SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; + testEnv["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = resolveAuthEmail( - config.auth.email, - undefined, - undefined, - new Set(["auth.email.max_frequency"]), + const resolved = run( + resolveAuthEmail( + config.auth.email, + undefined, + undefined, + new Set(["auth.email.max_frequency"]), + ), ); expect(resolved.max_frequency).toBe("1m"); }); it("still applies SUPABASE_AUTH_EMAIL_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; + testEnv["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = resolveAuthEmail(config.auth.email, undefined, undefined); + const resolved = run(resolveAuthEmail(config.auth.email, undefined, undefined)); expect(resolved.max_frequency).toBe("5s"); }); }); @@ -1316,285 +1372,285 @@ describe("resolveLocalConfigValues", () => { }; afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; + delete testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; + delete testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; + delete testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; }); it("leaves every hook disabled when nothing is configured or overridden", () => { - const resolved = resolveAuthHooks(undefined, allHooks, undefined); + const resolved = run(resolveAuthHooks(undefined, allHooks, undefined)); expect(resolved.customAccessToken.enabled).toBe(false); expect(resolved.mfaVerificationAttempt.enabled).toBe(false); }); it("overrides enabled/uri when the hook's section is present in the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://example.com/hook"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://example.com/hook"; const authDocument = { hook: { custom_access_token: { enabled: false } } }; - const resolved = resolveAuthHooks(authDocument, allHooks, undefined); + const resolved = run(resolveAuthHooks(authDocument, allHooks, undefined)); expect(resolved.customAccessToken.enabled).toBe(true); expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); expect(resolved.mfaVerificationAttempt.enabled).toBe(false); }); it("does not apply an env override when the hook's section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - const resolved = resolveAuthHooks({}, allHooks, undefined); + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; + const resolved = run(resolveAuthHooks({}, allHooks, undefined)); expect(resolved.customAccessToken.enabled).toBe(false); }); it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; const authDocument = { hook: { custom_access_token: { enabled: false } } }; expect(() => - resolveAuthHooks( - authDocument, - allHooks, - undefined, - new Set(["auth.hook.custom_access_token.enabled"]), + run( + resolveAuthHooks( + authDocument, + allHooks, + undefined, + new Set(["auth.hook.custom_access_token.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => resolveAuthHooks(authDocument, allHooks, undefined)).toThrow( + expect(() => run(resolveAuthHooks(authDocument, allHooks, undefined))).toThrow( 'cannot parse "not-a-bool" as a bool', ); }); it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; const hooksWithRemoteUri = { ...allHooks, custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, }; const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks( - authDocument, - hooksWithRemoteUri, - undefined, - new Set(["auth.hook.custom_access_token.uri"]), + const resolved = run( + resolveAuthHooks( + authDocument, + hooksWithRemoteUri, + undefined, + new Set(["auth.hook.custom_access_token.uri"]), + ), ); expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); }); it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; const hooksWithRemoteSecrets = { ...allHooks, custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, }; const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks( - authDocument, - hooksWithRemoteSecrets, - undefined, - new Set(["auth.hook.custom_access_token.secrets"]), + const resolved = run( + resolveAuthHooks( + authDocument, + hooksWithRemoteSecrets, + undefined, + new Set(["auth.hook.custom_access_token.secrets"]), + ), ); expect(resolved.customAccessToken.secrets).toBe("remote-secret"); }); it("still applies the env override for uri when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; const hooksWithLocalUri = { ...allHooks, custom_access_token: { enabled: true, uri: "https://local.example.com/hook", secrets: "" }, }; const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks(authDocument, hooksWithLocalUri, undefined); + const resolved = run(resolveAuthHooks(authDocument, hooksWithLocalUri, undefined)); expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); }); }); describe("resolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; + delete testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; }); it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; const mfa = baseConfig().auth.mfa; expect(() => - resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"])), + run(resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"]))), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; const mfa = baseConfig().auth.mfa; - expect(() => resolveAuthMfa(mfa, undefined)).toThrow('cannot parse "not-a-bool" as a bool'); + expect(() => run(resolveAuthMfa(mfa, undefined))).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); }); it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; + testEnv["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; const mfa = { ...baseConfig().auth.mfa, phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, }; - const resolved = resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.template"])); + const resolved = run(resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.template"]))); expect(resolved.phone.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; + delete testEnv["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; }); it("still applies SUPABASE_AUTH_MFA_PHONE_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; + testEnv["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; const mfa = { ...baseConfig().auth.mfa, phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, }; - const resolved = resolveAuthMfa(mfa, undefined); + const resolved = run(resolveAuthMfa(mfa, undefined)); expect(resolved.phone.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; + delete testEnv["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; }); it("prefers a remote-set auth.mfa.phone.max_frequency over a conflicting SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; + testEnv["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; const mfa = { ...baseConfig().auth.mfa, phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, }; - const resolved = resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.max_frequency"])); + const resolved = run( + resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.max_frequency"])), + ); expect(resolved.phone.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; + delete testEnv["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; }); it("still applies SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; + testEnv["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; const mfa = { ...baseConfig().auth.mfa, phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, }; - const resolved = resolveAuthMfa(mfa, undefined); + const resolved = run(resolveAuthMfa(mfa, undefined)); expect(resolved.phone.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; + delete testEnv["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; }); }); describe("resolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; }); it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; const authDocument = { email: { smtp: { enabled: true } } }; expect(() => - resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"])), + run(resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"]))), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( + expect(() => run(resolveAuthEmailSmtp(authDocument, undefined))).toThrow( 'cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.pass"]), + const resolved = run( + resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.pass"])), ); expect(resolved?.pass).toBe("remote-pass"); }); it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( + expect(() => run(resolveAuthEmailSmtp(authDocument, undefined))).toThrow( "failed to parse config: missing private key", ); }); it("prefers a remote-set auth.email.smtp.host over a conflicting SUPABASE_AUTH_EMAIL_SMTP_HOST", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.host"]), + const resolved = run( + resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.host"])), ); expect(resolved?.host).toBe("smtp.remote.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; }); it("still applies SUPABASE_AUTH_EMAIL_SMTP_HOST when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); + const resolved = run(resolveAuthEmailSmtp(authDocument, undefined)); expect(resolved?.host).toBe("smtp.env.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; }); it("prefers a remote-set auth.email.smtp.user over a conflicting SUPABASE_AUTH_EMAIL_SMTP_USER", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.user"]), + const resolved = run( + resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.user"])), ); expect(resolved?.user).toBe("remote-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"]; }); it("still applies SUPABASE_AUTH_EMAIL_SMTP_USER when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); + const resolved = run(resolveAuthEmailSmtp(authDocument, undefined)); expect(resolved?.user).toBe("env-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"]; }); it("prefers a remote-set auth.email.smtp.admin_email over a conflicting SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; const authDocument = { email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.admin_email"]), + const resolved = run( + resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.admin_email"])), ); expect(resolved?.adminEmail).toBe("remote@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; }); it("still applies SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; const authDocument = { email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); + const resolved = run(resolveAuthEmailSmtp(authDocument, undefined)); expect(resolved?.adminEmail).toBe("env@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; }); it("prefers a remote-set auth.email.smtp.sender_name over a conflicting SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.sender_name"]), + const resolved = run( + resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.sender_name"])), ); expect(resolved?.senderName).toBe("Remote Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; }); it("still applies SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); + const resolved = run(resolveAuthEmailSmtp(authDocument, undefined)); expect(resolved?.senderName).toBe("Env Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; }); }); @@ -1610,10 +1666,8 @@ describe("resolveLocalConfigValues", () => { }, }, }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, + const resolved = run( + resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), ); expect(resolved["my_custom"]?.enabled).toBe(true); expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); @@ -1625,7 +1679,7 @@ describe("resolveLocalConfigValues", () => { external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, }; expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), + run(resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined)), ).toThrow('cannot parse "not-a-bool" as a bool'); }); @@ -1633,10 +1687,8 @@ describe("resolveLocalConfigValues", () => { const authDocument = { external: { my_custom: { client_id: "custom-client-id" } }, }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, + const resolved = run( + resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), ); expect(resolved["my_custom"]?.enabled).toBe(false); }); @@ -1645,10 +1697,8 @@ describe("resolveLocalConfigValues", () => { const authDocument = { external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, + const resolved = run( + resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), ); expect(resolved["my_custom"]?.enabled).toBe(true); }); @@ -1658,7 +1708,7 @@ describe("resolveLocalConfigValues", () => { external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, }; expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), + run(resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined)), ).toThrow('cannot parse "1,2" as a bool'); }); @@ -1669,10 +1719,8 @@ describe("resolveLocalConfigValues", () => { SUPABASE_AUTH_EXTERNAL_APPLE_SECRET: "apple-secret", SUPABASE_AUTH_EXTERNAL_APPLE_URL: "https://appleid.apple.com", }; - const resolved = resolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, + const resolved = run( + resolveAuthExternalProviders(undefined, baseConfig().auth.external, projectEnvValues), ); expect(resolved["apple"]).toEqual({ enabled: true, @@ -1690,10 +1738,8 @@ describe("resolveLocalConfigValues", () => { SUPABASE_AUTH_EXTERNAL_GOOGLE_ENABLED: "true", SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID: "google-client-id", }; - const resolved = resolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, + const resolved = run( + resolveAuthExternalProviders(undefined, baseConfig().auth.external, projectEnvValues), ); expect(resolved["google"]).toBeUndefined(); expect(resolved["apple"]?.enabled).toBe(false); @@ -1706,11 +1752,13 @@ describe("resolveLocalConfigValues", () => { external: { my_custom: { enabled: true, secret: "remote-secret" } }, }; const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.secret"]), + const resolved = run( + resolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.secret"]), + ), ); expect(resolved["my_custom"]?.secret).toBe("remote-secret"); }); @@ -1721,7 +1769,9 @@ describe("resolveLocalConfigValues", () => { }; const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), + run( + resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), + ), ).toThrow("failed to parse config: missing private key"); }); @@ -1729,11 +1779,13 @@ describe("resolveLocalConfigValues", () => { const authDocument = { external: { my_custom: { enabled: true } } }; const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; expect(() => - resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.enabled"]), + run( + resolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.enabled"]), + ), ), ).not.toThrow(); }); @@ -1742,7 +1794,9 @@ describe("resolveLocalConfigValues", () => { const authDocument = { external: { my_custom: { enabled: true } } }; const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), + run( + resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), + ), ).toThrow('cannot parse "not-a-bool" as a bool'); }); @@ -1753,11 +1807,13 @@ describe("resolveLocalConfigValues", () => { const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.client_id"]), + const resolved = run( + resolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.client_id"]), + ), ); expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); }); @@ -1765,37 +1821,37 @@ describe("resolveLocalConfigValues", () => { describe("rawUnmodeledBool", () => { it("returns false for an absent value, matching Go's zero-value bool default", () => { - expect(rawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); + expect(run(rawUnmodeledBool(undefined, "auth.passkey.enabled"))).toBe(false); }); it("passes a real boolean through unchanged", () => { - expect(rawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); + expect(run(rawUnmodeledBool(true, "auth.passkey.enabled"))).toBe(true); + expect(run(rawUnmodeledBool(false, "auth.passkey.enabled"))).toBe(false); }); it("weakly coerces a raw number by truthiness, matching mapstructure's WeaklyTypedInput decodeBool", () => { - expect(rawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); - expect(rawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); + expect(run(rawUnmodeledBool(123, "auth.passkey.enabled"))).toBe(true); + expect(run(rawUnmodeledBool(0, "auth.passkey.enabled"))).toBe(false); + expect(run(rawUnmodeledBool(1.5, "auth.passkey.enabled"))).toBe(true); }); it("parses a valid boolean-ish string the way Go's strconv.ParseBool does", () => { - expect(rawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); - expect(rawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); + expect(run(rawUnmodeledBool("true", "auth.passkey.enabled"))).toBe(true); + expect(run(rawUnmodeledBool("False", "auth.passkey.enabled"))).toBe(false); + expect(run(rawUnmodeledBool("", "auth.passkey.enabled"))).toBe(false); }); it("throws on an unparsable string instead of silently disabling it", () => { - expect(() => rawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( + expect(() => run(rawUnmodeledBool("not-a-bool", "auth.passkey.enabled"))).toThrow( 'cannot parse "not-a-bool" as a bool', ); }); it("throws on an array or table value — mapstructure's decodeBool errors on these unconditionally, never weakly coerced", () => { - expect(() => rawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow( + expect(() => run(rawUnmodeledBool([1, 2], "auth.passkey.enabled"))).toThrow( InvalidBoolEnvOverrideError, ); - expect(() => rawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( + expect(() => run(rawUnmodeledBool({ nested: true }, "auth.passkey.enabled"))).toThrow( InvalidBoolEnvOverrideError, ); }); @@ -1829,98 +1885,102 @@ describe("resolveLocalConfigValues", () => { ]; afterEach(() => { - for (const name of ALL_OVERRIDE_NAMES) delete process.env[name]; + for (const name of ALL_OVERRIDE_NAMES) delete testEnv[name]; }); it("returns the configured settings unchanged when nothing is overridden", () => { const settings = { shared_buffers: "128MB", max_connections: 100 }; - expect(resolveDbSettingsEnvOverrides(settings, undefined)).toEqual(settings); + expect(run(resolveDbSettingsEnvOverrides(settings, undefined))).toEqual(settings); }); it("leaves an unconfigured field undefined when nothing is overridden", () => { - expect(resolveDbSettingsEnvOverrides({}, undefined).effective_cache_size).toBeUndefined(); + expect( + run(resolveDbSettingsEnvOverrides({}, undefined)).effective_cache_size, + ).toBeUndefined(); }); it("overrides a string field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = "256MB"; + testEnv["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = "256MB"; expect( - resolveDbSettingsEnvOverrides({ shared_buffers: "128MB" }, undefined).shared_buffers, + run(resolveDbSettingsEnvOverrides({ shared_buffers: "128MB" }, undefined)).shared_buffers, ).toBe("256MB"); }); it("sets a string field via the env var even when not configured at all", () => { - process.env["SUPABASE_DB_SETTINGS_WORK_MEM"] = "8MB"; - expect(resolveDbSettingsEnvOverrides({}, undefined).work_mem).toBe("8MB"); + testEnv["SUPABASE_DB_SETTINGS_WORK_MEM"] = "8MB"; + expect(run(resolveDbSettingsEnvOverrides({}, undefined)).work_mem).toBe("8MB"); }); it("overrides a uint field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "200"; + testEnv["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "200"; expect( - resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, + run(resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined)).max_connections, ).toBe(200); }); it("rejects a non-numeric uint override", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( + testEnv["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; + expect(() => run(resolveDbSettingsEnvOverrides({}, undefined))).toThrow( "Invalid db.settings.max_connections", ); }); it("resolves a 0x-prefixed uint override as hex", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "0x10"; + testEnv["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "0x10"; expect( - resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, + run(resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined)).max_connections, ).toBe(16); }); it("rejects a uint override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "18446744073709551616"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( + testEnv["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "18446744073709551616"; + expect(() => run(resolveDbSettingsEnvOverrides({}, undefined))).toThrow( "Failed reading config: Invalid db.settings.max_connections: 18446744073709551616.", ); }); it("overrides the boolean field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "true"; + testEnv["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "true"; expect( - resolveDbSettingsEnvOverrides({ track_commit_timestamp: false }, undefined) + run(resolveDbSettingsEnvOverrides({ track_commit_timestamp: false }, undefined)) .track_commit_timestamp, ).toBe(true); }); it("rejects a malformed boolean override", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "not-a-bool"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( + testEnv["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "not-a-bool"; + expect(() => run(resolveDbSettingsEnvOverrides({}, undefined))).toThrow( InvalidBoolEnvOverrideError, ); }); it("overrides the session_replication_role enum field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "replica"; + testEnv["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "replica"; expect( - resolveDbSettingsEnvOverrides({ session_replication_role: "origin" }, undefined) + run(resolveDbSettingsEnvOverrides({ session_replication_role: "origin" }, undefined)) .session_replication_role, ).toBe("replica"); }); it("leaves session_replication_role undefined when neither configured nor overridden", () => { - expect(resolveDbSettingsEnvOverrides({}, undefined).session_replication_role).toBeUndefined(); + expect( + run(resolveDbSettingsEnvOverrides({}, undefined)).session_replication_role, + ).toBeUndefined(); }); it("rejects an invalid session_replication_role override", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "invalid"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( + testEnv["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "invalid"; + expect(() => run(resolveDbSettingsEnvOverrides({}, undefined))).toThrow( InvalidSessionReplicationRoleEnvOverrideError, ); - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( + expect(() => run(resolveDbSettingsEnvOverrides({}, undefined))).toThrow( 'Invalid config for db.settings.session_replication_role: cannot parse "invalid" as one of "origin", "replica", "local"', ); }); it("also honors a projectEnvValues (dotenv) value", () => { expect( - resolveDbSettingsEnvOverrides({}, { SUPABASE_DB_SETTINGS_SHARED_BUFFERS: "512MB" }) + run(resolveDbSettingsEnvOverrides({}, { SUPABASE_DB_SETTINGS_SHARED_BUFFERS: "512MB" })) .shared_buffers, ).toBe("512MB"); }); @@ -1929,146 +1989,211 @@ describe("resolveLocalConfigValues", () => { describe("auth.signing_keys_path (asymmetric JWT signing)", () => { const tempRoot = useTempWorkdir("supabase-signing-keys-test-"); - it("signs anon/service_role with the first RS256 key in the file", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + it.live("signs anon/service_role with the first RS256 key in the file", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = yield* Effect.promise(() => importJWK(publicJwk, "RS256")); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; - const publicKey = await importJWK(publicJwk, "RS256"); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + const serviceRole = yield* Effect.promise(() => + jwtVerify(values.serviceRoleKey, publicKey), + ); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }).pipe(Effect.provide(testLayer)), + ); - const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); - expect(serviceRole.payload).toMatchObject({ role: "service_role" }); - }); + it.live("resolves a relative signing_keys_path against /supabase", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }).pipe(Effect.provide(testLayer)), + ); - it("resolves a relative signing_keys_path against /supabase", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey.split(".")).toHaveLength(3); - }); + it.live("uses an absolute signing_keys_path as-is, without joining the workdir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const absolutePath = path.join(tempRoot.current, "supabase", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); + const values = yield* resolveLocalConfigValues( + config, + "127.0.0.1", + "/some/unrelated/workdir", + ); + expect(values.anonKey.split(".")).toHaveLength(3); + }).pipe(Effect.provide(testLayer)), + ); - it("uses an absolute signing_keys_path as-is, without joining the workdir", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); - const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); - expect(values.anonKey.split(".")).toHaveLength(3); - }); + it.live("still prefers an explicit anon_key/service_role_key over signing keys", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { + signing_keys_path: "signing_keys.json", + anon_key: "configured-anon", + service_role_key: "configured-service-role", + }, + }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }).pipe(Effect.provide(testLayer)), + ); - it("still prefers an explicit anon_key/service_role_key over signing keys", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - const config = baseConfig({ - auth: { - signing_keys_path: "signing_keys.json", - anon_key: "configured-anon", - service_role_key: "configured-service-role", - }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey).toBe("configured-anon"); - expect(values.serviceRoleKey).toBe("configured-service-role"); - }); + it.live("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, []); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [, payload] = values.anonKey.split("."); + expect(yield* decodeJson(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject( + { + iss: "supabase-demo", + }, + ); + }).pipe(Effect.provide(testLayer)), + ); - it("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => { - writeSigningKeys(tempRoot.current, []); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - const [, payload] = values.anonKey.split("."); - expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ - iss: "supabase-demo", - }); - }); + it.live("throws a Go-worded error when the signing keys file does not exist", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(testLayer)), + ); - it("throws a Go-worded error when the signing keys file does not exist", () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", - ); - }); + it.live("throws a Go-worded error when the signing keys file is malformed JSON", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(tempRoot.current, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain('failed to decode signing keys: JSON Parse error: Unexpected identifier "not"'); + }).pipe(Effect.provide(testLayer)), + ); - it("throws a Go-worded error when the signing keys file is malformed JSON", () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - 'failed to decode signing keys: JSON Parse error: Unexpected identifier "not"', - ); - }); + it.live("keeps a leading BOM in the signing keys file, so the file fails to decode", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(tempRoot.current, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString( + path.join(supabaseDir, "signing_keys.json"), + `\uFEFF${yield* encodeJson([generateRsaJwk()])}`, + ); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to decode signing keys"); + }).pipe(Effect.provide(testLayer)), + ); - it("throws when the first key uses an unsupported algorithm", () => { - writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "unsupported algorithm: RS512", - ); - }); + it.live("throws when the first key uses an unsupported algorithm", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("unsupported algorithm: RS512"); + }).pipe(Effect.provide(testLayer)), + ); it("skips reading a missing signing_keys_path when auth is disabled", () => { const config = baseConfig({ auth: { enabled: false, signing_keys_path: "missing.json" }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); - it("skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", async () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - // Disabled auth with a configured signing-keys path still signs with the default ES256 key, - // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. - const publicKey = await importJWK( - { ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, - "ES256", - ); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "ES256", kid: DEFAULT_SIGNING_KEY.kid }); - }); + it.live( + "skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(tempRoot.current, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Disabled auth with a configured signing-keys path still signs with the default ES256 key, + // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. + const publicKey = yield* Effect.promise(() => + importJWK({ ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, "ES256"), + ); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "ES256", kid: DEFAULT_SIGNING_KEY.kid }); + }).pipe(Effect.provide(testLayer)), + ); describe("SUPABASE_AUTH_ENABLED env override", () => { // Reads the post-override `auth.enabled` value, not raw TOML, so an env-only disable/enable // still gates whether `signing_keys_path` is read. afterEach(() => { - delete process.env["SUPABASE_AUTH_ENABLED"]; + delete testEnv["SUPABASE_AUTH_ENABLED"]; }); it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_ENABLED"] = "false"; const config = baseConfig({ auth: { enabled: true, signing_keys_path: "missing.json" }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); - it("reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "true"; - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey.split(".")).toHaveLength(3); - }); + it.live( + "reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", + () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "true"; + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }).pipe(Effect.provide(testLayer)), + ); it("rejects a malformed override instead of falling back to the configured value", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false, signing_keys_path: "missing.json" }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))).toThrow( InvalidBoolEnvOverrideError, ); }); @@ -2080,34 +2205,34 @@ describe("resolveLocalConfigValues", () => { // mechanics are tested here. describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_ENABLED"]; - delete process.env["SUPABASE_AUTH_SITE_URL"]; + delete testEnv["SUPABASE_AUTH_ENABLED"]; + delete testEnv["SUPABASE_AUTH_SITE_URL"]; }); it("rejects an empty site_url when auth is enabled only via env", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_ENABLED"] = "true"; const config = baseConfig({ auth: { enabled: false, site_url: "" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: auth.site_url", ); }); it("does not throw when auth is disabled only via env, however empty site_url is", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_ENABLED"] = "false"; const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("accepts an env-provided site_url overriding an empty config.toml value", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; + testEnv["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("exposes the overridden site_url on the returned values, not just for validation", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; + testEnv["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; const config = baseConfig({ auth: { enabled: true, site_url: "http://127.0.0.1:3000" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.authSiteUrl).toBe("http://localhost:4000"); }); }); @@ -2127,21 +2252,21 @@ describe("resolveLocalConfigValues", () => { "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", ]; afterEach(() => { - for (const key of AUTH_SCALAR_ENV_KEYS) delete process.env[key]; + for (const key of AUTH_SCALAR_ENV_KEYS) delete testEnv[key]; }); it("overrides every flat auth.* scalar GoTrue needs, not just the ones Validate checks", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://issuer.example.com"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "7200"; - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = + testEnv["SUPABASE_AUTH_JWT_ISSUER"] = "https://issuer.example.com"; + testEnv["SUPABASE_AUTH_JWT_EXPIRY"] = "7200"; + testEnv["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://a.example.com,https://b.example.com"; - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "false"; - process.env["SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS"] = "true"; - process.env["SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION"] = "false"; - process.env["SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL"] = "20"; - process.env["SUPABASE_AUTH_ENABLE_MANUAL_LINKING"] = "true"; - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "12"; - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "lower_upper_letters_digits"; + testEnv["SUPABASE_AUTH_ENABLE_SIGNUP"] = "false"; + testEnv["SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS"] = "true"; + testEnv["SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION"] = "false"; + testEnv["SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL"] = "20"; + testEnv["SUPABASE_AUTH_ENABLE_MANUAL_LINKING"] = "true"; + testEnv["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "12"; + testEnv["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "lower_upper_letters_digits"; const config = baseConfig({ auth: { @@ -2156,7 +2281,7 @@ describe("resolveLocalConfigValues", () => { password_requirements: "", }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.authJwtIssuer).toBe("https://issuer.example.com"); expect(values.authJwtExpiry).toBe(7200); @@ -2174,9 +2299,9 @@ describe("resolveLocalConfigValues", () => { }); it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override, matching Go's UnmarshalText", () => { - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "bogus"; + testEnv["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "bogus"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Invalid auth.password_requirements: bogus", ); }); @@ -2187,43 +2312,43 @@ describe("resolveLocalConfigValues", () => { describe("auth.captcha env overrides", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; + delete testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"]; + delete testEnv["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; + delete testEnv["SUPABASE_AUTH_CAPTCHA_SECRET"]; }); it("rejects a captcha section enabled only via env with no provider", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; const config = baseConfig({ auth: { captcha: { enabled: false } } }); const document = { auth: { captcha: { enabled: false } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.captcha.provider"); }); it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; const config = baseConfig({ auth: { captcha: { enabled: true } } }); const document = { auth: { captcha: { enabled: true } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("accepts env-provided provider/secret overriding an enabled captcha section", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; + testEnv["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; + testEnv["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; const config = baseConfig({ auth: { captcha: { enabled: true } } }); const document = { auth: { captcha: { enabled: true } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); @@ -2231,45 +2356,44 @@ describe("resolveLocalConfigValues", () => { // `auth.passkey`/`auth.webauthn` have no decoded-schema presence signal, so these tests thread // a raw `document` object through explicitly instead of relying on `baseConfig`. afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; + delete testEnv["SUPABASE_AUTH_PASSKEY_ENABLED"]; + delete testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; + delete testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; }); it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; const config = baseConfig(); const document = { auth: { passkey: { enabled: false } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow( "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", ); }); it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = - "http://localhost:3000,http://localhost:3001"; + testEnv["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000,http://localhost:3001"; const config = baseConfig(); const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { const config = baseConfig(); const document = { auth: { passkey: { enabled: "not-a-bool" } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow('cannot parse "not-a-bool" as a bool'); }); }); @@ -2278,85 +2402,85 @@ describe("resolveLocalConfigValues", () => { // The hook schema always decodes a default `{ enabled: false }` regardless of file presence, // so presence here is read from the raw `document`, not the decoded `config`. afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; + delete testEnv["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; + delete testEnv["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; + delete testEnv["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; }); it("rejects a hook section enabled only via env with no uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; const config = baseConfig(); const document = { auth: { hook: { send_email: { enabled: false } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.hook.send_email.uri"); }); it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; + testEnv["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); const document = { auth: { hook: { send_email: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); describe("auth.email.smtp env overrides", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; + delete testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; }); it("rejects an smtp section enabled only via env with no host", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; const config = baseConfig(); const document = { auth: { email: { smtp: { enabled: false } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.email.smtp.host"); }); it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; const config = baseConfig(); const document = { auth: { email: { smtp: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; const config = baseConfig(); const document = { auth: { email: { smtp: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow(InvalidPortEnvOverrideError); }); it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); @@ -2364,29 +2488,29 @@ describe("resolveLocalConfigValues", () => { // These are plain value-typed fields with no presence gate, unlike hooks/smtp above — they're // overridable unconditionally. afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; + delete testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; + delete testEnv["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; }); it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", ); }); it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( InvalidBoolEnvOverrideError, ); }); @@ -2396,71 +2520,85 @@ describe("resolveLocalConfigValues", () => { // Same as auth.mfa above — including workos, whose default template omits the whole section // yet leaves it still unconditionally overridable. afterEach(() => { - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; + delete testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; + delete testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; }); it("rejects a third-party provider enabled only via env with no required field configured", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Invalid config: auth.third_party.firebase is enabled but without a project_id.", ); }); it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); it("does not enable a third-party provider purely from a required-field env override", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); describe("auth.email.template/notification (content_path validation)", () => { const tempRoot = useTempWorkdir("supabase-email-templates-test-"); - it("rejects a template content_path pointing at a missing file", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "missing-invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); + it.live("rejects a template content_path pointing at a missing file", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "missing-invite.html" } } }, + }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("Invalid config for auth.email.template.invite.content_path: "); + }).pipe(Effect.provide(testLayer)), + ); - it("rejects an absolute template content_path outside the project root", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "/etc/hosts" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', - ); - }); + it.live("rejects an absolute template content_path outside the project root", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "/etc/hosts" } } }, + }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain( + 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', + ); + }).pipe(Effect.provide(testLayer)), + ); - it("resolves a relative template content_path against the workdir itself, not /supabase", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + it.live( + "resolves a relative template content_path against the workdir itself, not /supabase", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tempRoot.current, "invite.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); it("does not throw a template with no content_path configured", () => { const config = baseConfig({ @@ -2470,44 +2608,53 @@ describe("resolveLocalConfigValues", () => { email: { template: { invite: {} } }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); - it("rejects an enabled notification content_path pointing at a missing file", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + it.live("rejects an enabled notification content_path pointing at a missing file", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", - ); - }); + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("Invalid config for auth.email.notification.password_changed.content_path: "); + }).pipe(Effect.provide(testLayer)), + ); - it("resolves a relative notification content_path against the workdir", () => { - const templateDir = join(tempRoot.current, "supabase", "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "pw-changed.html"), ""); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { - enabled: true, - content_path: "supabase/templates/pw-changed.html", + it.live("resolves a relative notification content_path against the workdir", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const templateDir = path.join(tempRoot.current, "supabase", "templates"); + yield* fs.makeDirectory(templateDir, { recursive: true }); + yield* fs.writeFileString(path.join(templateDir, "pw-changed.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { + enabled: true, + content_path: "supabase/templates/pw-changed.html", + }, }, }, }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); it("does not throw a disabled notification's missing content_path", () => { const config = baseConfig({ @@ -2521,14 +2668,18 @@ describe("resolveLocalConfigValues", () => { }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); it("does not throw a missing template content_path when auth is disabled", () => { const config = baseConfig({ auth: { enabled: false, email: { template: { invite: { content_path: "missing.html" } } } }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); it("rejects a template content key present without content_path", () => { @@ -2540,9 +2691,11 @@ describe("resolveLocalConfigValues", () => { }, }); expect(() => - resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { - auth: { email: { template: { invite: { content: "Hi" } } } }, - }), + run( + resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { + auth: { email: { template: { invite: { content: "Hi" } } } }, + }), + ), ).toThrow( "Invalid config for auth.email.template.invite.content: please use content_path instead", ); @@ -2555,44 +2708,51 @@ describe("resolveLocalConfigValues", () => { const tempRoot = useTempWorkdir("supabase-email-template-env-test-"); afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"]; - }); - - it("lets an env-provided template content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + delete testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; + delete testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"]; + delete testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; + delete testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; + delete testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"]; + }); + + it.live("lets an env-provided template content_path override a missing TOML content_path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tempRoot.current, "invite.html"), ""); + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); - it("rejects a notification enabled only via env with a missing content_path file", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: false, content_path: "missing.html" } }, + it.live("rejects a notification enabled only via env with a missing content_path file", () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: false, content_path: "missing.html" } }, + }, }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", - ); - }); + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("Invalid config for auth.email.notification.password_changed.content_path: "); + }).pipe(Effect.provide(testLayer)), + ); it("does not validate a notification disabled only via env despite a TOML-enabled section", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; const config = baseConfig({ auth: { enabled: true, @@ -2602,25 +2762,36 @@ describe("resolveLocalConfigValues", () => { }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); - it("lets an env-provided notification content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "pw-changed.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + it.live( + "lets an env-provided notification content_path override a missing TOML content_path", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(tempRoot.current, "pw-changed.html"), + "", + ); + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = + "pw-changed.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: true } } }, + }, + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); it("rejects a template _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; const config = baseConfig({ auth: { enabled: true, @@ -2628,13 +2799,13 @@ describe("resolveLocalConfigValues", () => { email: { template: { invite: {} } }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))).toThrow( "Invalid config for auth.email.template.invite.content: please use content_path instead", ); }); it("rejects an enabled notification's _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; const config = baseConfig({ auth: { enabled: true, @@ -2642,13 +2813,13 @@ describe("resolveLocalConfigValues", () => { email: { notification: { password_changed: { enabled: true } } }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))).toThrow( "Invalid config for auth.email.notification.password_changed.content: please use content_path instead", ); }); it("does not validate a disabled notification's _CONTENT env override", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; const config = baseConfig({ auth: { enabled: true, @@ -2656,88 +2827,112 @@ describe("resolveLocalConfigValues", () => { email: { notification: { password_changed: { enabled: false } } }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("lets a simultaneous template _CONTENT_PATH env override win over a _CONTENT env override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.template.invite.content_path"]), - ), + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), ).not.toThrow(); }); - it("still applies a template _CONTENT_PATH override to a missing file when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); + it.live( + "lets a simultaneous template _CONTENT_PATH env override win over a _CONTENT env override", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tempRoot.current, "invite.html"), ""); + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); - it("preserves a remote block's valid notification content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { enabled: true, content_path: "pw-changed.html" }, + it.live( + "preserves a remote block's valid template content_path over a missing-file ambient override", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tempRoot.current, "invite.html"), ""); + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, }, - }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.content_path"]), - ), - ).not.toThrow(); - }); + }); + yield* resolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.template.invite.content_path"]), + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.live( + "still applies a template _CONTENT_PATH override to a missing file when no remote block matched", + () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("Invalid config for auth.email.template.invite.content_path: "); + }).pipe(Effect.provide(testLayer)), + ); + + it.live( + "preserves a remote block's valid notification content_path over a missing-file ambient override", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(tempRoot.current, "pw-changed.html"), + "", + ); + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = + "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + yield* resolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.content_path"]), + ); + }).pipe(Effect.provide(testLayer)), + ); it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: true, @@ -2746,13 +2941,15 @@ describe("resolveLocalConfigValues", () => { }, }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.enabled"]), + ), ), ).not.toThrow(); }); @@ -2768,7 +2965,7 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig(); const document = { auth: { external: { custom: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.external.custom.client_id"); }); @@ -2778,7 +2975,7 @@ describe("resolveLocalConfigValues", () => { auth: { external: { custom: { enabled: true, client_id: "abc" } } }, }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.external.custom.secret"); }); @@ -2788,7 +2985,7 @@ describe("resolveLocalConfigValues", () => { auth: { external: { apple: { enabled: true, client_id: "abc" } } }, }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); @@ -2796,7 +2993,7 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig(); const document = { auth: { external: { slack: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); @@ -2804,46 +3001,46 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig(); const document = { auth: { external: { custom: { enabled: false } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("skips the check entirely when no document is threaded through", () => { const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); describe("auth.external env overrides", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; + delete testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; + delete testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; + delete testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; }); it("rejects a provider enabled only via env with no client_id", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; const config = baseConfig(); const document = { auth: { external: { custom: { enabled: false } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.external.custom.client_id"); }); it("accepts env-provided client_id/secret overriding a TOML-enabled provider missing both", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; + testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; + testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; const config = baseConfig(); const document = { auth: { external: { custom: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); @@ -2852,42 +3049,42 @@ describe("resolveLocalConfigValues", () => { // messagebird, textlocal, vonage), re-run here against the raw document with env overrides // applied. afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; - delete process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; + delete testEnv["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; + delete testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + delete testEnv["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; + delete testEnv["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; + delete testEnv["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; }); it("rejects a provider enabled only via env with missing required fields", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; const config = baseConfig(); const document = { auth: { sms: { twilio: { enabled: false } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.sms.twilio.account_sid"); }); it("accepts env-provided credentials overriding a TOML-enabled provider missing them", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; const config = baseConfig(); const document = { auth: { sms: { twilio: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("only validates the first enabled provider in Go's fixed priority order", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; const config = baseConfig(); const document = { auth: { sms: { twilio: { enabled: true }, messagebird: { enabled: false } } }, }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("Missing required field in config: auth.sms.messagebird.originator"); }); @@ -2895,19 +3092,19 @@ describe("resolveLocalConfigValues", () => { // Unlike the other 4 providers, twilio's presence isn't gated on the document — the default // config always registers `auth.sms.twilio.*`, so enabling only via env still fails // validation instead of doing nothing. - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Missing required field in config: auth.sms.twilio.account_sid", ); }); it("resolves a fully env-only twilio configuration with no auth.sms.twilio document section", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); + testEnv["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; + const resolved = run(resolveAuthSms(undefined, baseConfig().auth.sms, undefined)); expect(resolved.twilio.enabled).toBe(true); expect(resolved.twilio.account_sid).toBe("AC123"); expect(resolved.twilio.message_service_sid).toBe("MG123"); @@ -2917,34 +3114,34 @@ describe("resolveLocalConfigValues", () => { it("still does not synthesize messagebird purely from an env override when the section is absent from the document", () => { // Unlike twilio, messagebird has no entry in the default config template, so an absent // section genuinely means it was never registered. - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; + testEnv["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; const config = baseConfig(); - const resolved = resolveAuthSms(undefined, config.auth.sms, undefined); + const resolved = run(resolveAuthSms(undefined, config.auth.sms, undefined)); expect(resolved.messagebird.enabled).toBe(false); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).not.toThrow(); }); }); describe("resolveAuthSms (top-level scalars)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"]; - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + delete testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + delete testEnv["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"]; + delete testEnv["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + delete testEnv["SUPABASE_AUTH_SMS_TEMPLATE"]; }); it("overrides enable_signup/enable_confirmations/max_frequency/template with no presence gate", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"] = "true"; - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "Your OTP is {{ .Code }}"; + testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; + testEnv["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"] = "true"; + testEnv["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; + testEnv["SUPABASE_AUTH_SMS_TEMPLATE"] = "Your OTP is {{ .Code }}"; // A provider must be enabled, or `enable_signup` gets downgraded to false regardless of the // override. const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.enable_signup).toBe(true); expect(resolved.enable_confirmations).toBe(true); expect(resolved.max_frequency).toBe("10s"); @@ -2958,7 +3155,7 @@ describe("resolveLocalConfigValues", () => { max_frequency: "5s", twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.enable_signup).toBe(true); expect(resolved.max_frequency).toBe("5s"); }); @@ -2966,18 +3163,18 @@ describe("resolveLocalConfigValues", () => { describe("resolveAuthSms (disables phone login with no provider enabled)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + delete testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; }); it("downgrades enable_signup to false when configured true with no provider enabled", () => { const configured = { ...baseConfig().auth.sms, enable_signup: true }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.enable_signup).toBe(false); }); it("downgrades an env-overridden enable_signup to false with no provider enabled", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); + testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; + const resolved = run(resolveAuthSms(undefined, baseConfig().auth.sms, undefined)); expect(resolved.enable_signup).toBe(false); }); @@ -2987,214 +3184,201 @@ describe("resolveLocalConfigValues", () => { enable_signup: true, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.enable_signup).toBe(true); }); it("leaves enable_signup at false when already false with no provider enabled", () => { - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); + const resolved = run(resolveAuthSms(undefined, baseConfig().auth.sms, undefined)); expect(resolved.enable_signup).toBe(false); }); }); describe("resolveAuthSms — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; + delete testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + delete testEnv["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; + delete testEnv["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; }); it("suppresses a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when a remote block already set auth.sms.enable_signup", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; const configured = { ...baseConfig().auth.sms, enable_signup: true, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; expect(() => - resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"])), + run(resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"]))), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; const configured = { ...baseConfig().auth.sms, enable_signup: true, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; - expect(() => resolveAuthSms(undefined, configured, undefined)).toThrow( + expect(() => run(resolveAuthSms(undefined, configured, undefined))).toThrow( 'cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_SMS_VONAGE_ENABLED when a remote block already set auth.sms.vonage.enabled", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; expect(() => - resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.vonage.enabled"])), + run(resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.vonage.enabled"]))), ).not.toThrow(); }); it("prefers a remote-set auth.sms.vonage.api_secret over a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), + const resolved = run( + resolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), + ), ); expect(resolved.vonage.api_secret).toBe("remote-secret"); }); it("still rejects a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; const authDocument = { sms: { vonage: {} } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, }; - expect(() => resolveAuthSms(authDocument, configured, undefined)).toThrow( + expect(() => run(resolveAuthSms(authDocument, configured, undefined))).toThrow( "failed to parse config: missing private key", ); }); it("prefers a remote-set auth.sms.twilio.account_sid over a conflicting SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.twilio.account_sid"]), + const resolved = run( + resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.twilio.account_sid"])), ); expect(resolved.twilio.account_sid).toBe("remote-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + delete testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; }); it("still applies SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; + testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.twilio.account_sid).toBe("env-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + delete testEnv["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; }); it("prefers a remote-set auth.sms.vonage.from over a conflicting SUPABASE_AUTH_SMS_VONAGE_FROM", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; const authDocument = { sms: { vonage: { from: "remote-from" } } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, }; - const resolved = resolveAuthSms( - authDocument, - configured, - undefined, - new Set(["auth.sms.vonage.from"]), + const resolved = run( + resolveAuthSms(authDocument, configured, undefined, new Set(["auth.sms.vonage.from"])), ); expect(resolved.vonage.from).toBe("remote-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; + delete testEnv["SUPABASE_AUTH_SMS_VONAGE_FROM"]; }); it("still applies SUPABASE_AUTH_SMS_VONAGE_FROM when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; const authDocument = { sms: { vonage: { from: "remote-from" } } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, }; - const resolved = resolveAuthSms(authDocument, configured, undefined); + const resolved = run(resolveAuthSms(authDocument, configured, undefined)); expect(resolved.vonage.from).toBe("env-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; + delete testEnv["SUPABASE_AUTH_SMS_VONAGE_FROM"]; }); it("prefers a remote-set auth.sms.vonage.api_key over a conflicting SUPABASE_AUTH_SMS_VONAGE_API_KEY", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, }; - const resolved = resolveAuthSms( - authDocument, - configured, - undefined, - new Set(["auth.sms.vonage.api_key"]), + const resolved = run( + resolveAuthSms(authDocument, configured, undefined, new Set(["auth.sms.vonage.api_key"])), ); expect(resolved.vonage.api_key).toBe("remote-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; + delete testEnv["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; }); it("still applies SUPABASE_AUTH_SMS_VONAGE_API_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; + testEnv["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, }; - const resolved = resolveAuthSms(authDocument, configured, undefined); + const resolved = run(resolveAuthSms(authDocument, configured, undefined)); expect(resolved.vonage.api_key).toBe("env-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; + delete testEnv["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; }); it("prefers a remote-set auth.sms.template over a conflicting SUPABASE_AUTH_SMS_TEMPLATE", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; + testEnv["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.template"]), + const resolved = run( + resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.template"])), ); expect(resolved.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + delete testEnv["SUPABASE_AUTH_SMS_TEMPLATE"]; }); it("still applies SUPABASE_AUTH_SMS_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; + testEnv["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + delete testEnv["SUPABASE_AUTH_SMS_TEMPLATE"]; }); it("prefers a remote-set auth.sms.max_frequency over a conflicting SUPABASE_AUTH_SMS_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; + testEnv["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.max_frequency"]), + const resolved = run( + resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.max_frequency"])), ); expect(resolved.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + delete testEnv["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; }); it("still applies SUPABASE_AUTH_SMS_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; + testEnv["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = resolveAuthSms(undefined, configured, undefined); + const resolved = run(resolveAuthSms(undefined, configured, undefined)); expect(resolved.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + delete testEnv["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; }); it("still aborts resolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; const base = baseConfig(); const config: CliConfig = { ...base, @@ -3214,17 +3398,19 @@ describe("resolveLocalConfigValues", () => { }, }, }; - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'cannot parse "not-a-bool" as a bool', ); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.sms.enable_signup"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.sms.enable_signup"]), + ), ), ).not.toThrow(); }); @@ -3233,91 +3419,115 @@ describe("resolveLocalConfigValues", () => { describe("api.tls (cert/key validation)", () => { const tempRoot = useTempWorkdir("supabase-api-tls-test-"); - function writeTlsFile(workdir: string, name: string, contents = "dummy") { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); - } + const writeTlsFile = Effect.fnUntraced(function* ( + workdir: string, + name: string, + contents = "dummy", + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, name), contents); + }); it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { const config = baseConfig({ api: { tls: { enabled: true } } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); // The "exactly one of cert/key set" checks live in config-validate.unit.test.ts; the file-read // behavior below is tested here. - it("throws a Go-worded error when the configured cert file does not exist", () => { - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); + it.live("throws a Go-worded error when the configured cert file does not exist", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read TLS cert: "); + }).pipe(Effect.provide(testLayer)), + ); - it("throws a Go-worded error when the configured key file does not exist", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS key: ", - ); - }); + it.live("throws a Go-worded error when the configured key file does not exist", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read TLS key: "); + }).pipe(Effect.provide(testLayer)), + ); - it("succeeds when both cert_path and key_path are readable", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + it.live("succeeds when both cert_path and key_path are readable", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); - it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { - tls: { - enabled: true, - cert_path: "/cert.pem", - key_path: "/key.pem", - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + it.live( + "resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", + () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + }).pipe(Effect.provide(testLayer)), + ); it("skips TLS validation entirely when api is disabled", () => { const config = baseConfig({ api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { afterEach(() => { - delete process.env["SUPABASE_API_ENABLED"]; - delete process.env["SUPABASE_API_TLS_ENABLED"]; + delete testEnv["SUPABASE_API_ENABLED"]; + delete testEnv["SUPABASE_API_TLS_ENABLED"]; }); it("skips TLS validation when api is disabled only via env", () => { - process.env["SUPABASE_API_ENABLED"] = "false"; + testEnv["SUPABASE_API_ENABLED"] = "false"; const config = baseConfig({ api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + expect(() => + run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)), + ).not.toThrow(); }); it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + testEnv["SUPABASE_API_TLS_ENABLED"] = "true"; const config = baseConfig({ api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))).toThrow( "Missing required field in config: api.tls.key_path", ); }); @@ -3369,92 +3579,105 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", ]) { - delete process.env[name]; + delete testEnv[name]; } }); const tempRoot = useTempWorkdir("supabase-remote-signing-keys-test-"); - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.signing_keys_path"]), - ), - ).not.toThrow(); - }); + it.live( + "prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + yield* resolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.signing_keys_path"]), + ); + }).pipe(Effect.provide(testLayer)), + ); - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", - ); - }); + it.live( + "still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(testLayer)), + ); it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "abc"; const config = baseConfig({ db: { major_version: 14 } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.major_version"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.major_version"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_DB_MAJOR_VERSION when no remote block matched", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + testEnv["SUPABASE_DB_MAJOR_VERSION"] = "abc"; const config = baseConfig({ db: { major_version: 14 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Invalid db.major_version: abc", ); }); it("prefers a remote-set auth.jwt_secret over a conflicting SUPABASE_AUTH_JWT_SECRET", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; + testEnv["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; const config = baseConfig({ auth: { jwt_secret: "remote-supplied-secret-1234567890" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.jwt_secret"]), - ); - expect(values.jwtSecret).toBe("remote-supplied-secret-1234567890"); - }); - + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.jwt_secret"]), + ), + ); + expect(values.jwtSecret).toBe("remote-supplied-secret-1234567890"); + }); + it("prefers a remote-set db.root_key over a conflicting SUPABASE_DB_ROOT_KEY", () => { - process.env["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; + testEnv["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; const config = baseConfig(); const document = { db: { root_key: "remote-root-key" } }; - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["db.root_key"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["db.root_key"]), + ), ); expect(values.rootKey).toBe("remote-root-key"); }); it("prefers a remote-set auth.third_party.clerk.domain over a conflicting env override during validation", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; const config = baseConfig({ auth: { enabled: true, @@ -3462,26 +3685,28 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }, }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.third_party.clerk.enabled", "auth.third_party.clerk.domain"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.third_party.clerk.enabled", "auth.third_party.clerk.domain"]), + ), ), ).not.toThrow(); }); it("still rejects a conflicting SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; const config = baseConfig({ auth: { enabled: true, third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Invalid config: auth.third_party.clerk has invalid domain", ); }); @@ -3489,365 +3714,408 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi describe("api.tls.cert_path/key_path — remoteOverrideKeys (review: PRRT_kwDOErm0O86W8ZYk)", () => { const tempRoot = useTempWorkdir("supabase-api-tls-remote-test-"); - function writeTlsFile(workdir: string, name: string, contents = "dummy") { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); - } - - afterEach(() => { - delete process.env["SUPABASE_API_TLS_CERT_PATH"]; - delete process.env["SUPABASE_API_TLS_KEY_PATH"]; + const writeTlsFile = Effect.fnUntraced(function* ( + workdir: string, + name: string, + contents = "dummy", + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, name), contents); }); - it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { - // The env vars point at files that don't exist; the load only succeeds if the remote-set - // paths win instead. - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - process.env["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["api.tls.cert_path", "api.tls.key_path"]), - ), - ).not.toThrow(); - }); + afterEach(() => { + delete testEnv["SUPABASE_API_TLS_CERT_PATH"]; + delete testEnv["SUPABASE_API_TLS_KEY_PATH"]; + }); + + it.live( + "prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", + () => + Effect.gen(function* () { + // The env vars point at files that don't exist; the load only succeeds if the remote-set + // paths win instead. + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + testEnv["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + testEnv["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + yield* resolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["api.tls.cert_path", "api.tls.key_path"]), + ); + }).pipe(Effect.provide(testLayer)), + ); - it("still uses the env override when no remote block matched", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); + it.live("still uses the env override when no remote block matched", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + testEnv["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read TLS cert: "); + }).pipe(Effect.provide(testLayer)), + ); }); it("prefers remote-set api.port/api.tls.enabled/api.external_url over conflicting env overrides", () => { - process.env["SUPABASE_API_PORT"] = "9999"; - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; + testEnv["SUPABASE_API_PORT"] = "9999"; + testEnv["SUPABASE_API_TLS_ENABLED"] = "true"; + testEnv["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; const config = baseConfig({ api: { port: 54321, external_url: "", tls: { enabled: false } } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["api.port", "api.tls.enabled", "api.external_url"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["api.port", "api.tls.enabled", "api.external_url"]), + ), ); expect(values.apiUrl).toBe("http://127.0.0.1:54321"); }); it("prefers a remote-set db.port over a conflicting SUPABASE_DB_PORT", () => { - process.env["SUPABASE_DB_PORT"] = "9999"; + testEnv["SUPABASE_DB_PORT"] = "9999"; const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.port"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.port"]), + ), ); expect(values.dbPort).toBe(54322); expect(values.dbUrl).toContain(":54322/postgres"); }); it("prefers remote-set auth.site_url/auth.jwt_expiry over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; + testEnv["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; + testEnv["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; const config = baseConfig({ auth: { site_url: "https://remote.test", jwt_expiry: 3600 } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.site_url", "auth.jwt_expiry"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.site_url", "auth.jwt_expiry"]), + ), ); expect(values.authSiteUrl).toBe("https://remote.test"); expect(values.authJwtExpiry).toBe(3600); }); it("prefers remote-set auth.anon_key/auth.service_role_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; + testEnv["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; + testEnv["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; const config = baseConfig({ auth: { anon_key: "remote-anon-key", service_role_key: "remote-service-role-key" }, }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.anon_key", "auth.service_role_key"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.anon_key", "auth.service_role_key"]), + ), ); expect(values.anonKey).toBe("remote-anon-key"); expect(values.serviceRoleKey).toBe("remote-service-role-key"); }); it("suppresses a malformed SUPABASE_STUDIO_API_URL when a remote block already set studio.api_url", () => { - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + testEnv["SUPABASE_STUDIO_API_URL"] = "http://[::1"; const config = baseConfig({ studio: { api_url: "http://remote.test" } }); expect(() => + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.api_url"]), + ), + ), + ).not.toThrow(); + }); + + it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { + testEnv["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); + const values = run( resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, undefined, undefined, - new Set(["studio.api_url"]), + new Set(["studio.openai_api_key"]), ), - ).not.toThrow(); - }); - - it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.openai_api_key"]), ); expect(values.openaiApiKey).toBe("remote-openai-key"); }); it("still rejects a malformed SUPABASE_STUDIO_OPENAI_API_KEY when no remote block matched", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "failed to parse config: missing private key", ); }); it("prefers remote-set auth.publishable_key/auth.secret_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; const config = baseConfig({ auth: { publishable_key: "remote-publishable-key", secret_key: "remote-secret-key" }, }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.publishable_key", "auth.secret_key"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.publishable_key", "auth.secret_key"]), + ), ); expect(values.publishableKey).toBe("remote-publishable-key"); expect(values.secretKey).toBe("remote-secret-key"); }); it("still rejects a malformed SUPABASE_AUTH_PUBLISHABLE_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; const config = baseConfig({ auth: { publishable_key: "remote-publishable-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "failed to parse config: missing private key", ); }); it("still rejects a malformed SUPABASE_AUTH_SECRET_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + testEnv["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; const config = baseConfig({ auth: { secret_key: "remote-secret-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "failed to parse config: missing private key", ); }); it("suppresses a malformed SUPABASE_DB_SETTINGS_MAX_CONNECTIONS when the remote block set db.settings.max_connections", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; + testEnv["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; const config = baseConfig({ db: { settings: { max_connections: 100 } } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.settings.max_connections"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.settings.max_connections"]), + ), ), ).not.toThrow(); }); - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enabled"]), - ), - ).not.toThrow(); - }); + it.live( + "suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", + () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + yield* resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enabled"]), + ); + }).pipe(Effect.provide(testLayer)), + ); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', - ); - }); + it.live("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).message, + ).toContain('Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool'); + }).pipe(Effect.provide(testLayer)), + ); it("suppresses a malformed SUPABASE_ANALYTICS_ENABLED when a remote block already set analytics.enabled", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; const config = baseConfig({ analytics: { enabled: false } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_ANALYTICS_ENABLED when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; const config = baseConfig({ analytics: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for analytics.enabled: cannot parse "not-a-bool" as a bool', ); }); it("prefers a remote-set analytics.gcp_project_id over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_ID", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; + testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_project_id"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.gcp_project_id"]), + ), ); expect(values.gcpProjectId).toBe("remote-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; }); it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_ID when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; + testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.gcpProjectId).toBe("env-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; }); it("prefers a remote-set analytics.gcp_project_number over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; + testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_project_number"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.gcp_project_number"]), + ), ); expect(values.gcpProjectNumber).toBe("111"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; }); it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; + testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.gcpProjectNumber).toBe("999"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; }); it("prefers a remote-set analytics.gcp_jwt_path over a conflicting SUPABASE_ANALYTICS_GCP_JWT_PATH", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; + testEnv["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_jwt_path"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.gcp_jwt_path"]), + ), ); expect(values.gcpJwtPath).toBe("remote-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; }); it("still applies SUPABASE_ANALYTICS_GCP_JWT_PATH when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; + testEnv["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.gcpJwtPath).toBe("env-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + delete testEnv["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; }); it("prefers a remote-set auth.jwt_issuer over a conflicting SUPABASE_AUTH_JWT_ISSUER", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; + testEnv["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.jwt_issuer"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.jwt_issuer"]), + ), ); expect(values.authJwtIssuer).toBe("https://remote.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; + delete testEnv["SUPABASE_AUTH_JWT_ISSUER"]; }); it("still applies SUPABASE_AUTH_JWT_ISSUER when no remote block matched", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; + testEnv["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.authJwtIssuer).toBe("https://env.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; + delete testEnv["SUPABASE_AUTH_JWT_ISSUER"]; }); it("prefers a remote-set auth.additional_redirect_urls over a conflicting SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; + testEnv["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; const config = baseConfig({ auth: { additional_redirect_urls: ["https://remote.example.com"] }, }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.additional_redirect_urls"]), + const values = run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.additional_redirect_urls"]), + ), ); expect(values.authAdditionalRedirectUrls).toEqual(["https://remote.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + delete testEnv["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; }); it("still applies SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS when no remote block matched", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; + testEnv["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; const config = baseConfig({ auth: { additional_redirect_urls: ["https://remote.example.com"] }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)); expect(values.authAdditionalRedirectUrls).toEqual(["https://env.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + delete testEnv["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; }); describe("auth.webauthn.rp_id / auth.webauthn.rp_origins — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { @@ -3855,259 +4123,281 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi // validateResolvedConfig's own emptiness check on a document that leaves the field present // but empty. afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; + delete testEnv["SUPABASE_AUTH_PASSKEY_ENABLED"]; + delete testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; + delete testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; }); it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ID when a remote block already set (empty) auth.webauthn.rp_id", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, }; expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.webauthn.rp_id"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.webauthn.rp_id"]), + ), ), ).toThrow("Missing required field in config: auth.webauthn.rp_id"); }); it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ID when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when a remote block already set (empty) auth.webauthn.rp_origins", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, }; expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.webauthn.rp_origins"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.webauthn.rp_origins"]), + ), ), ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); }); it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + testEnv["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).not.toThrow(); }); }); it("suppresses a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when a remote block already set auth.third_party.firebase.enabled", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: true, third_party: { firebase: { enabled: false } } }, }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enabled", "auth.third_party.firebase.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enabled", "auth.third_party.firebase.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: true, third_party: { firebase: { enabled: false } } }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for auth.third_party.firebase.enabled: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when a remote block already set edge_runtime.deno_version", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["edge_runtime.deno_version"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["edge_runtime.deno_version"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when no remote block matched", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + testEnv["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( "Failed reading config: Invalid edge_runtime.deno_version: abc.", ); }); it("suppresses a malformed SUPABASE_API_ENABLED when a remote block already set api.enabled", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_API_ENABLED"] = "not-a-bool"; const config = baseConfig({ api: { enabled: false } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["api.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["api.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_API_ENABLED when no remote block matched", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_API_ENABLED"] = "not-a-bool"; const config = baseConfig({ api: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for api.enabled: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_STUDIO_ENABLED when a remote block already set studio.enabled", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; const config = baseConfig({ studio: { enabled: false } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_STUDIO_ENABLED when no remote block matched", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; const config = baseConfig({ studio: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for studio.enabled: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_STUDIO_PORT when a remote block already set studio.port", () => { - process.env["SUPABASE_STUDIO_PORT"] = "not-a-port"; + testEnv["SUPABASE_STUDIO_PORT"] = "not-a-port"; const config = baseConfig({ studio: { port: 54323 } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.port"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.port"]), + ), ), ).not.toThrow(); }); it("suppresses a malformed SUPABASE_LOCAL_SMTP_ENABLED when a remote block already set local_smtp.enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; const config = baseConfig({ local_smtp: { enabled: false } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["local_smtp.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["local_smtp.enabled"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_LOCAL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; const config = baseConfig({ local_smtp: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for local_smtp.enabled: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_ENABLE_SIGNUP when a remote block already set auth.enable_signup", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; const config = baseConfig({ auth: { enable_signup: false } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enable_signup"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enable_signup"]), + ), ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + testEnv["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; const config = baseConfig({ auth: { enable_signup: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).toThrow( 'Invalid config for auth.enable_signup: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH when a remote block already set auth.minimum_password_length", () => { - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; + testEnv["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; const config = baseConfig({ auth: { minimum_password_length: 8 } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.minimum_password_length"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.minimum_password_length"]), + ), ), ).not.toThrow(); }); it("suppresses a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when a remote block already set experimental.webhooks.enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; + testEnv["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.webhooks.enabled"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.webhooks.enabled"]), + ), ), ).not.toThrow(); }); it("suppresses a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when a remote block already set that hook's uri", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; const config = baseConfig({ auth: { hook: { @@ -4121,19 +4411,21 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.hook.custom_access_token.uri"]), + run( + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.hook.custom_access_token.uri"]), + ), ), ).not.toThrow(); }); it("still rejects a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + testEnv["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; const config = baseConfig({ auth: { hook: { @@ -4143,7 +4435,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + run(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)), ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); }); }); @@ -4151,218 +4443,276 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi describe("resolveLocalJwks", () => { const tempRoot = useTempWorkdir("supabase-local-jwks-test-"); - it("includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", async () => { - const config = baseConfig(); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - expect(JSON.parse(jwks)).toEqual({ - keys: [ - { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - }, - { kty: "oct", k: Buffer.from("a".repeat(32)).toString("base64url") }, - ], - }); - }); + it.live( + "includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", + () => + Effect.gen(function* () { + const config = baseConfig(); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + expect(yield* decodeJson(jwks)).toEqual({ + keys: [ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + }, + { kty: "oct", k: Buffer.from("a".repeat(32)).toString("base64url") }, + ], + }); + }).pipe(Effect.provide(testLayer)), + ); - it("publishes the public form of every signing key and omits the oct fallback", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ - kty: "RSA", - kid: "test-rsa-kid", - n: jwk["n"], - e: jwk["e"], - }); - expect(parsed.keys[0]).not.toHaveProperty("d"); - expect(parsed.keys[0]).not.toHaveProperty("p"); - expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); - }); + it.live("publishes the public form of every signing key and omits the oct fallback", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + const parsed = (yield* decodeJson(jwks)) as { keys: ReadonlyArray> }; - it("preserves a configured signing key's use/ext and filters key_ops to verify-only", async () => { - const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ + kty: "RSA", + kid: "test-rsa-kid", + n: jwk["n"], + e: jwk["e"], + }); + expect(parsed.keys[0]).not.toHaveProperty("d"); + expect(parsed.keys[0]).not.toHaveProperty("p"); + expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); - expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); - }); + it.live("preserves a configured signing key's use/ext and filters key_ops to verify-only", () => + Effect.gen(function* () { + const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + const parsed = (yield* decodeJson(jwks)) as { keys: ReadonlyArray> }; - it("falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - expect(JSON.parse(jwks)).toEqual({ - keys: [ - { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - }, - ], - }); - }); + expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); + }).pipe(Effect.provide(testLayer)), + ); - it("throws a Go-worded error when the signing keys file does not exist", async () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to read signing keys: ", - ); - }); + it.live( + "falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + expect(yield* decodeJson(jwks)).toEqual({ + keys: [ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + }, + ], + }); + }).pipe(Effect.provide(testLayer)), + ); - it("throws a Go-worded error when the signing keys file is malformed JSON", async () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to decode signing keys: ", - ); - }); + it.live("throws a Go-worded error when the signing keys file does not exist", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect( + (yield* Effect.flip(runLocalJwks(config, tempRoot.current, "a".repeat(32)))).message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(testLayer)), + ); + + it.live("throws a Go-worded error when the signing keys file is malformed JSON", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(tempRoot.current, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(runLocalJwks(config, tempRoot.current, "a".repeat(32)))).message, + ).toContain("failed to decode signing keys: "); + }).pipe(Effect.provide(testLayer)), + ); describe("auth.third_party", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("rejects an enabled third-party provider missing its required field", async () => { - const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); + it.live("rejects an enabled third-party provider missing its required field", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + }).pipe(Effect.provide(testLayer)), + ); - it("rejects more than one enabled third-party provider", async () => { - const config = baseConfig({ - auth: { - third_party: { - firebase: { enabled: true, project_id: "my-project" }, - workos: { enabled: true, issuer_url: "https://issuer.example" }, + it.live("rejects more than one enabled third-party provider", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + third_party: { + firebase: { enabled: true, project_id: "my-project" }, + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, }, - }, - }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - }); + }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + }).pipe(Effect.provide(testLayer)), + ); - it("does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", async () => { - const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; - const issuerUrl = "https://securetoken.google.com/my-project"; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === `${issuerUrl}/.well-known/openid-configuration`) { - return new Response(JSON.stringify({ jwks_uri: `${issuerUrl}/jwks.json` }), { - status: 200, - headers: { "content-type": "application/json" }, + it.live( + "does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", + () => + Effect.gen(function* () { + const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; + const issuerUrl = "https://securetoken.google.com/my-project"; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url === `${issuerUrl}/.well-known/openid-configuration`) { + return Promise.resolve( + new Response(JSON.stringify({ jwks_uri: `${issuerUrl}/jwks.json` }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === `${issuerUrl}/jwks.json`) { + return Promise.resolve( + new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); }); - } - if (url === `${issuerUrl}/jwks.json`) { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, + const config = baseConfig({ + auth: { + enabled: false, + third_party: { + firebase: { enabled: true, project_id: "my-project" }, + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, + }, }); - } - throw new Error(`unexpected fetch: ${url}`); - }); - const config = baseConfig({ - auth: { - enabled: false, - third_party: { - firebase: { enabled: true, project_id: "my-project" }, - workos: { enabled: true, issuer_url: "https://issuer.example" }, - }, - }, - }); - const jwksJson = await runLocalJwks(config, WORKDIR, "a".repeat(32)); - const jwks = JSON.parse(jwksJson) as { keys: ReadonlyArray<{ kid?: string }> }; - expect(jwks.keys.some((key) => key.kid === "firebase-key")).toBe(true); - fetchMock.mockRestore(); - }); + const jwksJson = yield* runLocalJwks(config, WORKDIR, "a".repeat(32)); + const jwks = (yield* decodeJson(jwksJson)) as { keys: ReadonlyArray<{ kid?: string }> }; + expect(jwks.keys.some((key) => key.kid === "firebase-key")).toBe(true); + fetchMock.mockRestore(); + }).pipe(Effect.provide(testLayer)), + ); - it('does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url, matching Go\'s issuerURL != "" check', async () => { - const fetchMock = vi.spyOn(globalThis, "fetch"); - const config = baseConfig({ - auth: { - enabled: false, - third_party: { workos: { enabled: true, issuer_url: "" } }, - }, - }); + it.live( + 'does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url, matching Go\'s issuerURL != "" check', + () => + Effect.gen(function* () { + const fetchMock = vi.spyOn(globalThis, "fetch"); + const config = baseConfig({ + auth: { + enabled: false, + third_party: { workos: { enabled: true, issuer_url: "" } }, + }, + }); - const jwksJson = await runLocalJwks(config, WORKDIR, "a".repeat(32)); - const jwks = JSON.parse(jwksJson) as { keys: ReadonlyArray }; + const jwksJson = yield* runLocalJwks(config, WORKDIR, "a".repeat(32)); + const jwks = (yield* decodeJson(jwksJson)) as { keys: ReadonlyArray }; - expect(fetchMock).not.toHaveBeenCalled(); - expect(jwks.keys.length).toBeGreaterThan(0); - fetchMock.mockRestore(); - }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(jwks.keys.length).toBeGreaterThan(0); + fetchMock.mockRestore(); + }).pipe(Effect.provide(testLayer)), + ); - it("fetches and includes the remote JWKS for an enabled third-party provider", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://issuer.example/.well-known/openid-configuration") { - return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === "https://issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); + it.live("fetches and includes the remote JWKS for an enabled third-party provider", () => + Effect.gen(function* () { + const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://issuer.example/.well-known/openid-configuration") { + return Promise.resolve( + new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === "https://issuer.example/jwks.json") { + return Promise.resolve( + new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); - const config = baseConfig({ - auth: { third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } } }, - }); - const jwks = await runLocalJwks(config, WORKDIR, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }); + const jwks = yield* runLocalJwks(config, WORKDIR, "a".repeat(32)); + const parsed = (yield* decodeJson(jwks)) as { + keys: ReadonlyArray>; + }; - expect(parsed.keys).toEqual( - expect.arrayContaining([expect.objectContaining({ kid: "remote-key" })]), - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); + expect(parsed.keys).toEqual( + expect.arrayContaining([expect.objectContaining({ kid: "remote-key" })]), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(testLayer)), + ); - it("fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", async () => { - vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("oidc discovery failed"); - }); + it.live( + "fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", + () => + Effect.gen(function* () { + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.reject(new Error("oidc discovery failed")), + ); - const config = baseConfig({ - auth: { third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } } }, - }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Failed to fetch https://issuer.example/.well-known/openid-configuration", - ); - }); + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain("Failed to fetch https://issuer.example/.well-known/openid-configuration"); + }).pipe(Effect.provide(testLayer)), + ); }); describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { @@ -4373,109 +4723,138 @@ describe("resolveLocalJwks", () => { "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", "SUPABASE_AUTH_ENABLED", ]) { - delete process.env[name]; + delete testEnv[name]; } }); - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks( - config, - tempRoot.current, - "a".repeat(32), - undefined, - new Set(["auth.signing_keys_path"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); - }); + it.live( + "prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* runLocalJwks( + config, + tempRoot.current, + "a".repeat(32), + undefined, + new Set(["auth.signing_keys_path"]), + ); + const parsed = (yield* decodeJson(jwks)) as { + keys: ReadonlyArray>; + }; + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); + }).pipe(Effect.provide(testLayer)), + ); - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to read signing keys: ", - ); - }); + it.live( + "still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(runLocalJwks(config, tempRoot.current, "a".repeat(32)))).message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(testLayer)), + ); - it("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://remote-issuer.example/.well-known/openid-configuration") { - return new Response( - JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - } - if (url === "https://remote-issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = - "https://env-should-not-win.test"; - const config = baseConfig({ - auth: { - third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, - }, - }); - const jwks = await runLocalJwks( - config, - WORKDIR, - "a".repeat(32), - undefined, - new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); - fetchMock.mockRestore(); - }); + it.live("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", () => + Effect.gen(function* () { + const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://remote-issuer.example/.well-known/openid-configuration") { + return Promise.resolve( + new Response( + JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + if (url === "https://remote-issuer.example/jwks.json") { + return Promise.resolve( + new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + testEnv["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; + testEnv["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = "https://env-should-not-win.test"; + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, + }, + }); + const jwks = yield* runLocalJwks( + config, + WORKDIR, + "a".repeat(32), + undefined, + new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), + ); + const parsed = (yield* decodeJson(jwks)) as { + keys: ReadonlyArray>; + }; + expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); + fetchMock.mockRestore(); + }).pipe(Effect.provide(testLayer)), + ); - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect( - runLocalJwks(config, WORKDIR, "a".repeat(32), undefined, new Set(["auth.enabled"])), - ).resolves.toEqual(expect.any(String)); - }); + it.live( + "suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", + () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect( + yield* runLocalJwks( + config, + WORKDIR, + "a".repeat(32), + undefined, + new Set(["auth.enabled"]), + ), + ).toEqual(expect.any(String)); + }).pipe(Effect.provide(testLayer)), + ); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', - ); - }); + it.live("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain('Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool'); + }).pipe(Effect.provide(testLayer)), + ); }); }); describe("resolveAuthExternalUrl — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_URL"]; + delete testEnv["SUPABASE_AUTH_EXTERNAL_URL"]; }); it("prefers a remote-set auth.external_url over a conflicting SUPABASE_AUTH_EXTERNAL_URL", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; + testEnv["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; const document = { auth: { external_url: "https://remote.test" } }; - expect(resolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"]))).toBe( + expect(run(resolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"])))).toBe( "https://remote.test", ); }); it("still applies SUPABASE_AUTH_EXTERNAL_URL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; + testEnv["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; const document = { auth: { external_url: "https://configured.test" } }; - expect(resolveAuthExternalUrl(document, undefined)).toBe("https://env-wins.test"); + expect(run(resolveAuthExternalUrl(document, undefined))).toBe("https://env-wins.test"); }); }); @@ -4483,48 +4862,65 @@ describe("resolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow pro const tempRoot = useTempWorkdir("supabase-configured-signing-keys-test-"); afterEach(() => { - delete process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; - delete process.env["SUPABASE_AUTH_ENABLED"]; + delete testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; + delete testEnv["SUPABASE_AUTH_ENABLED"]; }); - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const keys = resolveConfiguredSigningKeys( - config, - tempRoot.current, - undefined, - new Set(["auth.signing_keys_path"]), - ); - expect(keys).toHaveLength(1); - expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); - }); + it.live( + "prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", + () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const keys = yield* resolveConfiguredSigningKeys( + config, + tempRoot.current, + undefined, + new Set(["auth.signing_keys_path"]), + ); + expect(keys).toHaveLength(1); + expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); + }).pipe(Effect.provide(testLayer)), + ); - it("still reads the env-overridden path when no remote block matched", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - "failed to read signing keys: ", - ); - }); + it.live("still reads the env-overridden path when no remote block matched", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + testEnv["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveConfiguredSigningKeys(config, tempRoot.current, undefined))) + .message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(testLayer)), + ); - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - resolveConfiguredSigningKeys(config, tempRoot.current, undefined, new Set(["auth.enabled"])), - ).not.toThrow(); - }); + it.live( + "suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", + () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + yield* resolveConfiguredSigningKeys( + config, + tempRoot.current, + undefined, + new Set(["auth.enabled"]), + ); + }).pipe(Effect.provide(testLayer)), + ); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', - ); - }); + it.live("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => + Effect.gen(function* () { + testEnv["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect( + (yield* Effect.flip(resolveConfiguredSigningKeys(config, tempRoot.current, undefined))) + .message, + ).toContain('Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool'); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/cli/src/command-internal/local-project-context.ts b/apps/cli/src/command-internal/local-project-context.ts index b223e10253..6b910eeb3a 100644 --- a/apps/cli/src/command-internal/local-project-context.ts +++ b/apps/cli/src/command-internal/local-project-context.ts @@ -158,7 +158,7 @@ export const loadLocalProjectContext = ( /** Records OrioleDB selection for commands whose only local config read is this context. */ export const recordLocalProjectOrioleDbTelemetry = (context: LocalProjectContext) => - Effect.try(() => ({ + Effect.all({ version: envOverrideOrioleDBVersion( context.config.db.orioledb_version, context.projectEnvValues, @@ -167,7 +167,7 @@ export const recordLocalProjectOrioleDbTelemetry = (context: LocalProjectContext context.config.db.major_version, context.projectEnvValues, ), - })).pipe( + }).pipe( Effect.flatMap(({ version, majorVersion }) => recordOrioleDbTelemetry(version, majorVersion)), Effect.ignore, ); diff --git a/apps/cli/src/command-internal/path-containment.ts b/apps/cli/src/command-internal/path-containment.ts index 5c23981748..1359d05a6e 100644 --- a/apps/cli/src/command-internal/path-containment.ts +++ b/apps/cli/src/command-internal/path-containment.ts @@ -4,10 +4,9 @@ * `content_path` values and the `supabase/notebooks/` directory. */ -import { lstatSync, readlinkSync, realpathSync, type Stats } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { Effect, FileSystem, Option, Path, type PlatformError, Predicate, Result } from "effect"; -// `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when +// `readLink` bypasses the OS's own `ELOOP` symlink-cycle detection when // manually following a dangling/unsearchable/looping symlink one hop at a // time (see `canonicalizeExistingPath` below), so that manual follow needs // its own explicit bound. @@ -20,84 +19,94 @@ const MAX_SYMLINK_FOLLOW_DEPTH = 40; * happens to start with two dots (e.g. `..templates`) is a distinct, * in-root path and must not be rejected. */ -export function isPathContainedInRoot(root: string, candidatePath: string): boolean { - const rel = relative(root, candidatePath); - return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); +export function isPathContainedInRoot( + path: Path.Path, + root: string, + candidatePath: string, +): boolean { + const rel = path.relative(root, candidatePath); + return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`)); } /** - * Canonicalizes `path` when it exists (per `lstatSync`), or returns `undefined` so + * Canonicalizes `path` when it exists (per `realPath`/`readLink`), or returns `undefined` so * {@link canonicalPathForContainment} keeps walking up to an existing ancestor. * - * `realpathSync` can throw for a path that exists (dangling symlink, `EACCES` target, `ELOOP`), + * `realPath` can fail for a path that exists (dangling symlink, `EACCES` target, `ELOOP`), * so such a symlink is followed one hop by hand, bounded by {@link MAX_SYMLINK_FOLLOW_DEPTH}, * and its target canonicalized in turn; a chain still unresolved at the bound returns the lexical - * path so a loop is rejected rather than accepted. An `lstatSync` failure unrelated to the path + * path so a loop is rejected rather than accepted. A `readLink` failure unrelated to the path * itself (unreadable ancestor, over-long name) counts as "doesn't exist yet", so an honest in-root * path behind a restricted ancestor isn't falsely rejected. */ -function canonicalizeExistingPath(path: string, depth: number): string | undefined { - try { - return realpathSync(path); - } catch { - // Wraps only the `lstatSync` call, not the recursive canonicalization below it: including - // that would let a deep throw there return the outer symlink's own lexically-in-root path, - // turning a rejection into an accept. - let entry: Stats | undefined; - try { - entry = lstatSync(path, { throwIfNoEntry: false }); - } catch { - return undefined; +const canonicalizeExistingPath = Effect.fnUntraced(function* (path: string, depth: number) { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const canonical = yield* fs.realPath(path).pipe(Effect.option); + if (Option.isSome(canonical)) return canonical.value; + /* `FileSystem` has no `lstat`, so `readLink` stands in: it succeeds only for a symlink, and a + symlink whose own mode denies reading it fails with `EACCES` under a searchable parent. */ + const link = yield* fs.readLink(path).pipe(Effect.result); + const isSymbolicLink = + Result.isSuccess(link) || + (Predicate.isTagged(link.failure.reason, "PermissionDenied") && + (yield* fs.stat(`${pathService.dirname(path)}${pathService.sep}.`).pipe(Effect.isSuccess))); + if (isSymbolicLink) { + if (depth < MAX_SYMLINK_FOLLOW_DEPTH) { + const target = yield* Effect.fromResult(link); + return yield* canonicalPathForContainment( + pathService.isAbsolute(target) + ? target + : pathService.join(pathService.dirname(path), target), + depth + 1, + ); } - if (entry === undefined) return undefined; - if (entry.isSymbolicLink()) { - if (depth < MAX_SYMLINK_FOLLOW_DEPTH) { - const target = readlinkSync(path); - return canonicalPathForContainment( - isAbsolute(target) ? target : join(dirname(path), target), - depth + 1, - ); - } - // Must not be treated as "doesn't exist": returning `undefined` here would let the - // ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it - // instead of failing closed. - return path; - } - // A non-symlink entry `lstat` can see but `realpath` can't resolve — e.g. a chmod-000 - // directory on Darwin, whose realpath(3) needs search permission on itself, not just its - // parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing - // path, since a plain entry can't recurse into a loop. - return undefined; + // Must not be treated as "doesn't exist": returning `undefined` here would let the + // ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it + // instead of failing closed. + return path; } -} + // A non-symlink entry that exists but `realPath` can't resolve — e.g. a chmod-000 + // directory on Darwin, whose realpath(3) needs search permission on itself, not just its + // parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing + // path, since a plain entry can't recurse into a loop. + return undefined; +}); /** * Canonicalizes `path` for the containment check, tolerating a path (or an ancestor of it) * that genuinely doesn't exist yet — the normal case for a missing template file or a * notebooks directory a pull is about to create, which should surface as a missing-file error, * not a containment error. Walks up to the deepest existing ancestor, resolves it with - * `realpathSync` (dereferencing any symlinks, including a symlinked project root itself), then + * `realPath` (dereferencing any symlinks, including a symlinked project root itself), then * re-appends the missing tail lexically. The walk-up is iterative, not recursive, so it stays * correct against a pathologically long chain of missing ancestors; each ancestor still goes * through {@link canonicalizeExistingPath}, so an intermediate dangling/unsearchable/looping * symlink is followed rather than lexically skipped. */ -export function canonicalPathForContainment(path: string, depth = 0): string { - const canonical = canonicalizeExistingPath(path, depth); - if (canonical !== undefined) return canonical; +export const canonicalPathForContainment: ( + path: string, + depth?: number, +) => Effect.Effect = + Effect.fnUntraced(function* (path: string, depth = 0) { + const pathService = yield* Path.Path; + const canonical = yield* canonicalizeExistingPath(path, depth); + if (canonical !== undefined) return canonical; - const tail: string[] = [basename(path)]; - let current = dirname(path); - for (;;) { - const ancestorCanonical = canonicalizeExistingPath(current, depth); - if (ancestorCanonical !== undefined) { - return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical); - } - const parent = dirname(current); - if (parent === current) { - return resolve(tail.reduceRight((acc, name) => join(acc, name), current)); + const tail: string[] = [pathService.basename(path)]; + let current = pathService.dirname(path); + for (;;) { + const ancestorCanonical = yield* canonicalizeExistingPath(current, depth); + if (ancestorCanonical !== undefined) { + return tail.reduceRight((acc, name) => pathService.join(acc, name), ancestorCanonical); + } + const parent = pathService.dirname(current); + if (parent === current) { + return pathService.resolve( + tail.reduceRight((acc, name) => pathService.join(acc, name), current), + ); + } + tail.push(pathService.basename(current)); + current = parent; } - tail.push(basename(current)); - current = parent; - } -} + }); diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 2a843e74e1..5e897e7d05 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -210,13 +210,13 @@ const EDGE_RUNTIME_INSPECTOR_PORT: StackEndpointSetting = { }; /** The configured port when the setting is present in `config.toml` or its env var, else undefined. */ -const resolvePort = ( +const resolvePort = Effect.fnUntraced(function* ( setting: StackEndpointSetting, document: Readonly> | undefined, configured: number, env: Readonly>, -): number | undefined => { - if (envOverride(setting.envVar, undefined, env) !== undefined) return configured; +) { + if ((yield* envOverride(setting.envVar, undefined, env)) !== undefined) return configured; const path = setting.configPath.split("."); const key = path[path.length - 1] ?? ""; const parent = path @@ -226,7 +226,7 @@ const resolvePort = ( document, ); return typeof parent?.[key] === "number" ? configured : undefined; -}; +}); /** * Maps a saved stack endpoint (service + endpoint name) to the `StackEndpointSetting` that controls it. @@ -294,26 +294,27 @@ const authProviderNames = [ "zoom", ] as const; -const resolvedPort = ( +const resolvedPort = Effect.fnUntraced(function* ( name: string, configured: number, field: string, env: Readonly>, -) => - envOverride(name, undefined, env) === undefined +) { + return (yield* envOverride(name, undefined, env)) === undefined ? configured - : envOverridePort(name, configured, field, env); + : yield* envOverridePort(name, configured, field, env); +}); -const resolveAuthOverrides = ( +const resolveAuthOverrides = Effect.fnUntraced(function* ( auth: CliConfig["auth"], document: Readonly> | undefined, env: Readonly>, -) => { +) { const authDocument = section(document, "auth"); - const email = resolveAuthEmail(auth.email, authDocument, env); - const smtp = resolveAuthEmailSmtp(authDocument, env); - const hooks = resolveAuthHooks(authDocument, auth.hook, env); - const external = resolveAuthExternalProviders(authDocument, auth.external, env); + const email = yield* resolveAuthEmail(auth.email, authDocument, env); + const smtp = yield* resolveAuthEmailSmtp(authDocument, env); + const hooks = yield* resolveAuthHooks(authDocument, auth.hook, env); + const external = yield* resolveAuthExternalProviders(authDocument, auth.external, env); const externalResolved = { ...auth.external }; for (const name of authProviderNames) { const value = external[name]; @@ -381,7 +382,7 @@ const resolveAuthOverrides = ( template: resolvedEmailTemplate, notification: resolvedEmailNotification, }; - const additionalRedirectUrls = envOverride( + const additionalRedirectUrls = yield* envOverride( "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, env, @@ -398,73 +399,80 @@ const resolveAuthOverrides = ( admin_email: smtp.adminEmail, sender_name: smtp.senderName, }; - const resolvedSms = resolveAuthSms(authDocument, auth.sms, env); + const resolvedSms = yield* resolveAuthSms(authDocument, auth.sms, env); return { ...auth, - enabled: envOverrideBool("SUPABASE_AUTH_ENABLED", auth.enabled, "auth.enabled", env), - site_url: envOverride("SUPABASE_AUTH_SITE_URL", auth.site_url, env), + enabled: yield* envOverrideBool("SUPABASE_AUTH_ENABLED", auth.enabled, "auth.enabled", env), + site_url: yield* envOverride("SUPABASE_AUTH_SITE_URL", auth.site_url, env), additional_redirect_urls: additionalRedirectUrls === undefined ? auth.additional_redirect_urls : strToArr(additionalRedirectUrls), - jwt_expiry: envOverrideUint( + jwt_expiry: yield* envOverrideUint( "SUPABASE_AUTH_JWT_EXPIRY", "auth.jwt_expiry", auth.jwt_expiry, env, ), - enable_refresh_token_rotation: envOverrideBool( + enable_refresh_token_rotation: yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", auth.enable_refresh_token_rotation, "auth.enable_refresh_token_rotation", env, ), - refresh_token_reuse_interval: envOverrideUint( + refresh_token_reuse_interval: yield* envOverrideUint( "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", "auth.refresh_token_reuse_interval", auth.refresh_token_reuse_interval, env, ), - enable_manual_linking: envOverrideBool( + enable_manual_linking: yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", auth.enable_manual_linking, "auth.enable_manual_linking", env, ), - enable_signup: envOverrideBool( + enable_signup: yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_SIGNUP", auth.enable_signup, "auth.enable_signup", env, ), - enable_anonymous_sign_ins: envOverrideBool( + enable_anonymous_sign_ins: yield* envOverrideBool( "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", auth.enable_anonymous_sign_ins, "auth.enable_anonymous_sign_ins", env, ), - minimum_password_length: envOverrideUint( + minimum_password_length: yield* envOverrideUint( "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "auth.minimum_password_length", auth.minimum_password_length, env, ), - password_requirements: envOverrideAuthPasswordRequirements(auth.password_requirements, env), - publishable_key: envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", auth.publishable_key, env), - secret_key: envOverride("SUPABASE_AUTH_SECRET_KEY", auth.secret_key, env), - anon_key: envOverride("SUPABASE_AUTH_ANON_KEY", auth.anon_key, env), - service_role_key: envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, env), - rate_limit: resolveGotrueRateLimit(auth.rate_limit, env), - captcha: resolveAuthCaptcha(authDocument, auth.captcha, env), + password_requirements: yield* envOverrideAuthPasswordRequirements( + auth.password_requirements, + env, + ), + publishable_key: yield* envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", auth.publishable_key, env), + secret_key: yield* envOverride("SUPABASE_AUTH_SECRET_KEY", auth.secret_key, env), + anon_key: yield* envOverride("SUPABASE_AUTH_ANON_KEY", auth.anon_key, env), + service_role_key: yield* envOverride( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + auth.service_role_key, + env, + ), + rate_limit: yield* resolveGotrueRateLimit(auth.rate_limit, env), + captcha: yield* resolveAuthCaptcha(authDocument, auth.captcha, env), hook: hookResolved, - mfa: resolveAuthMfa(auth.mfa, env), - sessions: resolveGotrueSessions(auth.sessions, env), + mfa: yield* resolveAuthMfa(auth.mfa, env), + sessions: yield* resolveGotrueSessions(auth.sessions, env), email: { ...resolvedEmail, smtp: resolvedSmtp }, sms: { ...resolvedSms, twilio: { ...resolvedSms.twilio, - content_sid: envOverride( + content_sid: yield* envOverride( "SUPABASE_AUTH_SMS_TWILIO_CONTENT_SID", auth.sms.twilio.content_sid, env, @@ -472,16 +480,16 @@ const resolveAuthOverrides = ( }, }, external: externalResolved, - web3: resolveGotrueWeb3(auth.web3, env), - oauth_server: resolveGotrueOAuthServer(auth.oauth_server, env), + web3: yield* resolveGotrueWeb3(auth.web3, env), + oauth_server: yield* resolveGotrueOAuthServer(auth.oauth_server, env), }; -}; +}); -const resolveEffectiveCliConfig = ( +const resolveEffectiveCliConfig = Effect.fnUntraced(function* ( config: CliConfig, document: Readonly> | undefined, env: Readonly>, -): CliConfig => { +) { const api = config.api; const db = config.db; const storage = config.storage; @@ -492,10 +500,14 @@ const resolveEffectiveCliConfig = ( const pooler = db.pooler; const edge = config.edge_runtime; const experimental = config.experimental; - const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, env); - const apiExtraSearchPathOverride = envOverride("SUPABASE_API_EXTRA_SEARCH_PATH", undefined, env); + const apiSchemasOverride = yield* envOverride("SUPABASE_API_SCHEMAS", undefined, env); + const apiExtraSearchPathOverride = yield* envOverride( + "SUPABASE_API_EXTRA_SEARCH_PATH", + undefined, + env, + ); const imageDocument = section(section(document, "storage"), "image_transformation"); - const imageEnabledOverride = envOverride( + const imageEnabledOverride = yield* envOverride( "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", undefined, env, @@ -504,25 +516,30 @@ const resolveEffectiveCliConfig = ( imageDocument?.enabled !== undefined || imageEnabledOverride !== undefined; const resolvedApi = { ...api, - enabled: envOverrideBool("SUPABASE_API_ENABLED", api.enabled, "api.enabled", env), - port: resolvedPort("SUPABASE_API_PORT", api.port, "api.port", env), + enabled: yield* envOverrideBool("SUPABASE_API_ENABLED", api.enabled, "api.enabled", env), + port: yield* resolvedPort("SUPABASE_API_PORT", api.port, "api.port", env), schemas: apiSchemasOverride === undefined ? api.schemas : strToArr(apiSchemasOverride), extra_search_path: apiExtraSearchPathOverride === undefined ? api.extra_search_path : strToArr(apiExtraSearchPathOverride), - max_rows: envOverrideApiMaxRows(api.max_rows, env), + max_rows: yield* envOverrideApiMaxRows(api.max_rows, env), tls: { ...api.tls, - enabled: envOverrideBool("SUPABASE_API_TLS_ENABLED", api.tls.enabled, "api.tls.enabled", env), - cert_path: envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, env), - key_path: envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, env), + enabled: yield* envOverrideBool( + "SUPABASE_API_TLS_ENABLED", + api.tls.enabled, + "api.tls.enabled", + env, + ), + cert_path: yield* envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, env), + key_path: yield* envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, env), }, - external_url: envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, env), + external_url: yield* envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, env), auto_expose_new_tables: api.auto_expose_new_tables !== undefined || - envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", undefined, env) !== undefined - ? envOverrideBool( + (yield* envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", undefined, env)) !== undefined + ? yield* envOverrideBool( "SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", api.auto_expose_new_tables ?? false, "api.auto_expose_new_tables", @@ -532,8 +549,13 @@ const resolveEffectiveCliConfig = ( }; const resolvedStorage = { ...storage, - enabled: envOverrideBool("SUPABASE_STORAGE_ENABLED", storage.enabled, "storage.enabled", env), - file_size_limit: envOverride( + enabled: yield* envOverrideBool( + "SUPABASE_STORAGE_ENABLED", + storage.enabled, + "storage.enabled", + env, + ), + file_size_limit: yield* envOverride( "SUPABASE_STORAGE_FILE_SIZE_LIMIT", String(storage.file_size_limit), env, @@ -541,7 +563,7 @@ const resolveEffectiveCliConfig = ( image_transformation: imageEnabledExplicit ? { ...storage.image_transformation, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", storage.image_transformation?.enabled ?? false, "storage.image_transformation.enabled", @@ -551,7 +573,7 @@ const resolveEffectiveCliConfig = ( : undefined, s3_protocol: { ...storage.s3_protocol, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", storage.s3_protocol.enabled, "storage.s3_protocol.enabled", @@ -560,25 +582,25 @@ const resolveEffectiveCliConfig = ( }, analytics: { ...storage.analytics, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_STORAGE_ANALYTICS_ENABLED", storage.analytics.enabled, "storage.analytics.enabled", env, ), - max_namespaces: envOverrideUint( + max_namespaces: yield* envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", "storage.analytics.max_namespaces", storage.analytics.max_namespaces, env, ), - max_tables: envOverrideUint( + max_tables: yield* envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", "storage.analytics.max_tables", storage.analytics.max_tables, env, ), - max_catalogs: envOverrideUint( + max_catalogs: yield* envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", "storage.analytics.max_catalogs", storage.analytics.max_catalogs, @@ -587,19 +609,19 @@ const resolveEffectiveCliConfig = ( }, vector: { ...storage.vector, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_STORAGE_VECTOR_ENABLED", storage.vector.enabled, "storage.vector.enabled", env, ), - max_buckets: envOverrideUint( + max_buckets: yield* envOverrideUint( "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", "storage.vector.max_buckets", storage.vector.max_buckets, env, ), - max_indexes: envOverrideUint( + max_indexes: yield* envOverrideUint( "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", "storage.vector.max_indexes", storage.vector.max_indexes, @@ -609,15 +631,15 @@ const resolveEffectiveCliConfig = ( }; const resolvedEdge = { ...edge, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_EDGE_RUNTIME_ENABLED", edge.enabled, "edge_runtime.enabled", env, ), - policy: envOverrideEdgeRuntimePolicy(edge.policy, env), - deno_version: envOverrideDenoVersion(edge.deno_version, env), - inspector_port: resolvedPort( + policy: yield* envOverrideEdgeRuntimePolicy(edge.policy, env), + deno_version: yield* envOverrideDenoVersion(edge.deno_version, env), + inspector_port: yield* resolvedPort( "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", edge.inspector_port, "edge_runtime.inspector_port", @@ -626,36 +648,44 @@ const resolveEffectiveCliConfig = ( }; const resolvedAnalytics = { ...analytics, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_ANALYTICS_ENABLED", analytics.enabled, "analytics.enabled", env, ), - port: resolvedPort("SUPABASE_ANALYTICS_PORT", analytics.port, "analytics.port", env), - backend: envOverrideAnalyticsBackend(analytics.backend, env), - gcp_project_id: envOverride("SUPABASE_ANALYTICS_GCP_PROJECT_ID", analytics.gcp_project_id, env), - gcp_project_number: envOverride( + port: yield* resolvedPort("SUPABASE_ANALYTICS_PORT", analytics.port, "analytics.port", env), + backend: yield* envOverrideAnalyticsBackend(analytics.backend, env), + gcp_project_id: yield* envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + analytics.gcp_project_id, + env, + ), + gcp_project_number: yield* envOverride( "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", analytics.gcp_project_number, env, ), - gcp_jwt_path: envOverride("SUPABASE_ANALYTICS_GCP_JWT_PATH", analytics.gcp_jwt_path, env), + gcp_jwt_path: yield* envOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + analytics.gcp_jwt_path, + env, + ), }; const resolvedPooler = { ...pooler, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_DB_POOLER_ENABLED", pooler.enabled, "db.pooler.enabled", env, ), - port: resolvedPort("SUPABASE_DB_POOLER_PORT", pooler.port, "db.pooler.port", env), - pool_mode: envOverridePoolMode(pooler.pool_mode, env), - default_pool_size: envOverrideDefaultPoolSize(pooler.default_pool_size, env), - max_client_conn: envOverrideMaxClientConn(pooler.max_client_conn, env), + port: yield* resolvedPort("SUPABASE_DB_POOLER_PORT", pooler.port, "db.pooler.port", env), + pool_mode: yield* envOverridePoolMode(pooler.pool_mode, env), + default_pool_size: yield* envOverrideDefaultPoolSize(pooler.default_pool_size, env), + max_client_conn: yield* envOverrideMaxClientConn(pooler.max_client_conn, env), }; - const authEnabled = envOverrideBool( + const authEnabled = yield* envOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, "auth.enabled", @@ -663,75 +693,75 @@ const resolveEffectiveCliConfig = ( ); const thirdParty = { firebase: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", config.auth.third_party.firebase.enabled, "auth.third_party.firebase.enabled", env, ), - project_id: envOverride( + project_id: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", config.auth.third_party.firebase.project_id, env, ), }, auth0: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", config.auth.third_party.auth0.enabled, "auth.third_party.auth0.enabled", env, ), - tenant: envOverride( + tenant: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", config.auth.third_party.auth0.tenant, env, ), - tenant_region: envOverride( + tenant_region: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", config.auth.third_party.auth0.tenant_region, env, ), }, aws_cognito: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", config.auth.third_party.aws_cognito.enabled, "auth.third_party.aws_cognito.enabled", env, ), - user_pool_id: envOverride( + user_pool_id: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", config.auth.third_party.aws_cognito.user_pool_id, env, ), - user_pool_region: envOverride( + user_pool_region: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", config.auth.third_party.aws_cognito.user_pool_region, env, ), }, clerk: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", config.auth.third_party.clerk.enabled, "auth.third_party.clerk.enabled", env, ), - domain: envOverride( + domain: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", config.auth.third_party.clerk.domain, env, ), }, workos: { - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", config.auth.third_party.workos.enabled, "auth.third_party.workos.enabled", env, ), - issuer_url: envOverride( + issuer_url: yield* envOverride( "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", config.auth.third_party.workos.issuer_url, env, @@ -741,17 +771,17 @@ const resolveEffectiveCliConfig = ( // JWT security settings apply to non-Auth workloads too, so resolve them // regardless of whether the Auth capability is enabled. const authResolved = { - ...(authEnabled ? resolveAuthOverrides(config.auth, document, env) : config.auth), + ...(authEnabled ? yield* resolveAuthOverrides(config.auth, document, env) : config.auth), enabled: authEnabled, third_party: thirdParty, - jwt_issuer: envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, env), - signing_keys_path: envOverride( + jwt_issuer: yield* envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, env), + signing_keys_path: yield* envOverride( "SUPABASE_AUTH_SIGNING_KEYS_PATH", config.auth.signing_keys_path, env, ), - jwt_secret: decryptAuthSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, env), + jwt_secret: yield* decryptAuthSecret( + yield* envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, env), env, ), }; @@ -761,25 +791,25 @@ const resolveEffectiveCliConfig = ( auth: authResolved, db: { ...db, - port: resolvedPort("SUPABASE_DB_PORT", db.port, "db.port", env), - major_version: envOverrideMajorVersion(db.major_version, env), - health_timeout: envOverride("SUPABASE_DB_HEALTH_TIMEOUT", db.health_timeout, env), - orioledb_version: envOverrideOrioleDBVersion(db.orioledb_version, env), - settings: resolveDbSettingsEnvOverrides(db.settings, env), + port: yield* resolvedPort("SUPABASE_DB_PORT", db.port, "db.port", env), + major_version: yield* envOverrideMajorVersion(db.major_version, env), + health_timeout: yield* envOverride("SUPABASE_DB_HEALTH_TIMEOUT", db.health_timeout, env), + orioledb_version: yield* envOverrideOrioleDBVersion(db.orioledb_version, env), + settings: yield* resolveDbSettingsEnvOverrides(db.settings, env), pooler: resolvedPooler, }, edge_runtime: resolvedEdge, // Same S3 overrides legacy Compose applies, so the OrioleDB S3 guard sees env-only values. experimental: { ...experimental, - s3_host: envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", experimental.s3_host, env), - s3_region: envOverride("SUPABASE_EXPERIMENTAL_S3_REGION", experimental.s3_region, env), - s3_access_key: envOverride( + s3_host: yield* envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", experimental.s3_host, env), + s3_region: yield* envOverride("SUPABASE_EXPERIMENTAL_S3_REGION", experimental.s3_region, env), + s3_access_key: yield* envOverride( "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", experimental.s3_access_key, env, ), - s3_secret_key: envOverride( + s3_secret_key: yield* envOverride( "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", experimental.s3_secret_key, env, @@ -787,53 +817,58 @@ const resolveEffectiveCliConfig = ( }, realtime: { ...realtime, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_REALTIME_ENABLED", realtime.enabled, "realtime.enabled", env, ), - ip_version: envOverrideRealtimeIpVersion(realtime.ip_version, env), - max_header_length: envOverrideRealtimeMaxHeaderLength(realtime.max_header_length, env), + ip_version: yield* envOverrideRealtimeIpVersion(realtime.ip_version, env), + max_header_length: yield* envOverrideRealtimeMaxHeaderLength(realtime.max_header_length, env), }, storage: resolvedStorage, analytics: resolvedAnalytics, studio: { ...studio, - enabled: envOverrideBool("SUPABASE_STUDIO_ENABLED", studio.enabled, "studio.enabled", env), - port: resolvedPort("SUPABASE_STUDIO_PORT", studio.port, "studio.port", env), - api_url: envOverride("SUPABASE_STUDIO_API_URL", studio.api_url, env), - openai_api_key: decryptAuthSecret( - envOverride("SUPABASE_STUDIO_OPENAI_API_KEY", studio.openai_api_key, env), + enabled: yield* envOverrideBool( + "SUPABASE_STUDIO_ENABLED", + studio.enabled, + "studio.enabled", + env, + ), + port: yield* resolvedPort("SUPABASE_STUDIO_PORT", studio.port, "studio.port", env), + api_url: yield* envOverride("SUPABASE_STUDIO_API_URL", studio.api_url, env), + openai_api_key: yield* decryptAuthSecret( + yield* envOverride("SUPABASE_STUDIO_OPENAI_API_KEY", studio.openai_api_key, env), env, ), }, local_smtp: { ...mail, - enabled: envOverrideBool( + enabled: yield* envOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", mail.enabled, "local_smtp.enabled", env, ), - port: resolvedPort("SUPABASE_LOCAL_SMTP_PORT", mail.port, "local_smtp.port", env), - smtp_port: resolvedPort( + port: yield* resolvedPort("SUPABASE_LOCAL_SMTP_PORT", mail.port, "local_smtp.port", env), + smtp_port: yield* resolvedPort( "SUPABASE_LOCAL_SMTP_SMTP_PORT", mail.smtp_port ?? 0, "local_smtp.smtp_port", env, ), - pop3_port: resolvedPort( + pop3_port: yield* resolvedPort( "SUPABASE_LOCAL_SMTP_POP3_PORT", mail.pop3_port ?? 0, "local_smtp.pop3_port", env, ), - admin_email: envOverride("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", mail.admin_email, env), - sender_name: envOverride("SUPABASE_LOCAL_SMTP_SENDER_NAME", mail.sender_name, env), + admin_email: yield* envOverride("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", mail.admin_email, env), + sender_name: yield* envOverride("SUPABASE_LOCAL_SMTP_SENDER_NAME", mail.sender_name, env), }, - }; -}; + } satisfies CliConfig; +}); const unsupportedConfigPaths = [ { path: "api.tls", active: (config: CliConfig) => config.api.enabled }, @@ -947,18 +982,18 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( projectRoot, (message) => new StackConfigError({ message }), )); - const effectiveInput = yield* Effect.try({ - try: () => - resolveEffectiveCliConfig( - context.config, - context.loaded?.document, - context.projectEnvValues, - ), - catch: (cause) => - new StackConfigError({ - message: cause instanceof Error ? cause.message : "invalid config overrides", - }), - }); + const effectiveInput = yield* resolveEffectiveCliConfig( + context.config, + context.loaded?.document, + context.projectEnvValues, + ).pipe( + Effect.mapError( + (cause) => + new StackConfigError({ + message: cause instanceof Error ? cause.message : "invalid config overrides", + }), + ), + ); const validatedConfig = yield* validateCliConfig(withoutUndefined(effectiveInput)).pipe( Effect.mapError((cause) => { const issues = SchemaIssue.makeFormatterStandardSchemaV1({ @@ -982,29 +1017,32 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( return yield* new StackConfigError({ message: validationError }); if (context.loaded === null) yield* rejectFrozenWithoutConfig(projectRoot, opts?.frozen === true); - const externalProviders = yield* Effect.try({ - try: () => - resolveAuthExternalProviders( - section(context.loaded?.document, "auth"), - validatedConfig.auth.external, - context.projectEnvValues, - ), - catch: (cause) => - new StackConfigError({ - message: cause instanceof Error ? cause.message : "invalid auth provider config", - }), - }); + const externalProviders = yield* resolveAuthExternalProviders( + section(context.loaded?.document, "auth"), + validatedConfig.auth.external, + context.projectEnvValues, + ).pipe( + Effect.mapError( + (cause) => + new StackConfigError({ + message: cause instanceof Error ? cause.message : "invalid auth provider config", + }), + ), + ); const authConfig = yield* resolveAuthConfig( validatedConfig.auth, validatedConfig.local_smtp, { - authExternalUrl: resolveAuthExternalUrl( + authExternalUrl: yield* resolveAuthExternalUrl( context.loaded?.document, context.projectEnvValues, ), apiExternalUrl: validatedConfig.api.external_url, externalProviders, - ...resolveGotruePasskeyWebauthn(context.loaded?.document, context.projectEnvValues), + ...(yield* resolveGotruePasskeyWebauthn( + context.loaded?.document, + context.projectEnvValues, + ).pipe(Effect.orDie)), }, ); const path = yield* Path.Path; @@ -1022,29 +1060,36 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( message: cause instanceof Error ? cause.message : String(cause), }), }); + const toStackConfigError = (cause: unknown) => + cause instanceof StackConfigError + ? cause + : new StackConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }); const localKeys = yield* Effect.cached( - Effect.try({ - try: () => { - const configured = (value: string | undefined) => - value === undefined || value === "" - ? undefined - : decryptAuthSecret(value, context.projectEnvValues); - const publishableKey = configured(auth.publishable_key); - const secretKey = configured(auth.secret_key); - const configuredAnonKey = configured(auth.anon_key); - const configuredServiceRoleKey = configured(auth.service_role_key); - const configuredSigningKeys = resolveConfiguredSigningKeys( - validatedConfig, - projectRoot, - context.projectEnvValues, - ); - const signingKeys = - configuredSigningKeys ?? - (auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 - ? undefined - : [DEFAULT_SIGNING_KEY]); - const signingKey = signingKeys?.[0]; - return { + Effect.gen(function* () { + const configured = Effect.fnUntraced(function* (value: string | undefined) { + return value === undefined || value === "" + ? undefined + : yield* decryptAuthSecret(value, context.projectEnvValues); + }); + const publishableKey = yield* configured(auth.publishable_key); + const secretKey = yield* configured(auth.secret_key); + const configuredAnonKey = yield* configured(auth.anon_key); + const configuredServiceRoleKey = yield* configured(auth.service_role_key); + const configuredSigningKeys = yield* resolveConfiguredSigningKeys( + validatedConfig, + projectRoot, + context.projectEnvValues, + ); + const signingKeys = + configuredSigningKeys ?? + (auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 + ? undefined + : [DEFAULT_SIGNING_KEY]); + const signingKey = signingKeys?.[0]; + return yield* Effect.try({ + try: () => ({ ...(publishableKey === undefined ? {} : { publishableKey }), ...(secretKey === undefined ? {} : { secretKey }), ...(configuredAnonKey === undefined @@ -1065,13 +1110,14 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( gotrueJwtKeys: encodeJwkArray(signingKeys), publicSigningKeys: encodeJwkArray(signingKeys.map(toPublicJwk)), }), - }; - }, - catch: (cause) => - new StackConfigError({ - message: cause instanceof Error ? cause.message : String(cause), }), - }), + catch: toStackConfigError, + }); + }).pipe( + Effect.mapError(toStackConfigError), + Effect.provideService(FileSystem.FileSystem, yield* FileSystem.FileSystem), + Effect.provideService(Path.Path, path), + ), ); const remoteJwks = Effect.gen(function* () { return issuer === undefined @@ -1151,16 +1197,15 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ), catch: (cause) => new StackConfigError({ message: String(cause) }), }); - const functionsEnv = yield* Effect.try({ - try: () => - Object.fromEntries( - Object.entries(validatedConfig.edge_runtime.secrets ?? {}).map(([key, value]) => [ - key, - decryptAuthSecret(value, context.projectEnvValues) ?? "", - ]), - ), - catch: (cause) => new StackConfigError({ message: String(cause) }), - }); + const functionsEnv = Object.fromEntries( + yield* Effect.forEach( + Object.entries(validatedConfig.edge_runtime.secrets ?? {}), + ([key, value]) => + decryptAuthSecret(value, context.projectEnvValues).pipe( + Effect.map((decrypted) => [key, decrypted ?? ""] as const), + ), + ).pipe(Effect.mapError((cause) => new StackConfigError({ message: String(cause) }))), + ); const storageFileSizeLimit = yield* Effect.try({ try: () => String(parseFileSizeLimit(validatedConfig.storage.file_size_limit)), catch: (cause) => @@ -1171,72 +1216,68 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( catch: (cause) => new StackConfigError({ message: `Invalid db.health_timeout: ${String(cause)}` }), }); - const configuredJwtSecret = yield* Effect.try({ - try: () => - validatedConfig.auth.jwt_secret === undefined || validatedConfig.auth.jwt_secret === "" - ? undefined - : resolveJwtSecret(validatedConfig.auth.jwt_secret), - catch: (cause) => new StackConfigError({ message: String(cause) }), - }); + const configuredJwtSecret = + validatedConfig.auth.jwt_secret === undefined || validatedConfig.auth.jwt_secret === "" + ? undefined + : yield* resolveJwtSecret(validatedConfig.auth.jwt_secret).pipe( + Effect.mapError((cause) => new StackConfigError({ message: String(cause) })), + ); const jwtSecret = configuredJwtSecret === undefined ? undefined : Redacted.make(configuredJwtSecret); const document = context.loaded?.document; - const rootKey = yield* Effect.try({ - try: () => { - const raw = section(document, "db")?.root_key; - if (raw !== undefined && typeof raw !== "string") - throw new Error("db.root_key must be a string"); - const value = decryptAuthSecret( - envOverride("SUPABASE_DB_ROOT_KEY", raw, context.projectEnvValues), - context.projectEnvValues, - ); - return value === undefined || value === "" ? undefined : value; - }, - catch: (cause) => new StackConfigError({ message: String(cause) }), + const rootKey = yield* Effect.gen(function* () { + const raw = section(document, "db")?.root_key; + if (raw !== undefined && typeof raw !== "string") + return yield* new StackConfigError({ message: "Error: db.root_key must be a string" }); + const value = yield* decryptAuthSecret( + yield* envOverride("SUPABASE_DB_ROOT_KEY", raw, context.projectEnvValues), + context.projectEnvValues, + ).pipe(Effect.mapError((cause) => new StackConfigError({ message: String(cause) }))); + return value === undefined || value === "" ? undefined : value; }); - const dbPort = resolvePort( + const dbPort = yield* resolvePort( DB_PORT, document, validatedConfig.db.port, context.projectEnvValues, ); - const apiPort = resolvePort( + const apiPort = yield* resolvePort( API_PORT, document, validatedConfig.api.port, context.projectEnvValues, ); - const studioPort = resolvePort( + const studioPort = yield* resolvePort( STUDIO_PORT, document, validatedConfig.studio.port, context.projectEnvValues, ); - const poolerPort = resolvePort( + const poolerPort = yield* resolvePort( DB_POOLER_PORT, document, validatedConfig.db.pooler.port, context.projectEnvValues, ); - const mailPort = resolvePort( + const mailPort = yield* resolvePort( LOCAL_SMTP_PORT, document, validatedConfig.local_smtp.port, context.projectEnvValues, ); - const mailSmtpPort = resolvePort( + const mailSmtpPort = yield* resolvePort( LOCAL_SMTP_SMTP_PORT, document, validatedConfig.local_smtp.smtp_port ?? 0, context.projectEnvValues, ); - const mailPop3Port = resolvePort( + const mailPop3Port = yield* resolvePort( LOCAL_SMTP_POP3_PORT, document, validatedConfig.local_smtp.pop3_port ?? 0, context.projectEnvValues, ); - const analyticsPort = resolvePort( + const analyticsPort = yield* resolvePort( ANALYTICS_PORT, document, validatedConfig.analytics.port, @@ -1267,7 +1308,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const createCreations = ( stackId: string, ): Effect.Effect, StackConfigError> => - Effect.sync(() => { + Effect.gen(function* () { return [ { service: "database", @@ -1410,7 +1451,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( endpoints: { http: endpoint(apiPort), inspector: endpoint( - resolvePort( + yield* resolvePort( EDGE_RUNTIME_INSPECTOR_PORT, document, validatedConfig.edge_runtime.inspector_port, diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index ff8240a8cd..782624ab35 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -1,4 +1,5 @@ import type { CliConfig } from "@supabase/config"; +import { Effect } from "effect"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; import { serviceContainerIds } from "./docker-ids.ts"; @@ -209,7 +210,7 @@ export interface StatusState { } /** - * The validating half of {@link StatusState}: everything that can throw, and none of it + * The validating half of {@link StatusState}: everything that can fail, and none of it * depends on `excluded`/`containerIds`. Split out so `status.handler.ts` can resolve and * validate this before any Docker call — a bad `auth.jwt_secret` or malformed * `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override must fail here, not be masked by a @@ -228,21 +229,21 @@ export interface StatusLocalState { } /** - * Resolves local config values (URLs, keys — can throw, see {@link resolveLocalConfigValues}) + * Resolves local config values (URLs, keys — can fail, see {@link resolveLocalConfigValues}) * and the per-service `.enabled` gates, with no reference to `excluded`/`containerIds` — see * {@link gateStatusState} for the Docker-dependent half this composes with. Each `.enabled` * gate is read through {@link envOverrideBool}, not the raw decoded `config.
.enabled`, * so an env-overridden stack's running services match what `status` reports. * - * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv override - * doesn't parse as a valid bool. - * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, or its + * Fails with {@link InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * Fails with {@link InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * Fails with {@link InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * Fails when `auth.signing_keys_path` is set but the file is missing, malformed, or its * first key is unsupported — see {@link generateAsymmetricGoJwt}. */ -export function resolveStatusLocalState( +export const resolveStatusLocalState = Effect.fnUntraced(function* ( config: CliConfig, hostname: string, workdir: string, @@ -258,48 +259,48 @@ export function resolveStatusLocalState( * containers. */ precomputedLocal?: LocalConfigValues, -): StatusLocalState { +) { const local = precomputedLocal ?? - resolveLocalConfigValues(config, hostname, workdir, projectEnvValues, document); + (yield* resolveLocalConfigValues(config, hostname, workdir, projectEnvValues, document)); - const apiEnabled = envOverrideBool( + const apiEnabled = yield* envOverrideBool( "SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues, ); - const studioSectionEnabled = envOverrideBool( + const studioSectionEnabled = yield* envOverrideBool( "SUPABASE_STUDIO_ENABLED", config.studio.enabled, "studio.enabled", projectEnvValues, ); - const authSectionEnabled = envOverrideBool( + const authSectionEnabled = yield* envOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, "auth.enabled", projectEnvValues, ); - const inbucketSectionEnabled = envOverrideBool( + const inbucketSectionEnabled = yield* envOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", config.local_smtp.enabled, "local_smtp.enabled", projectEnvValues, ); - const storageSectionEnabled = envOverrideBool( + const storageSectionEnabled = yield* envOverrideBool( "SUPABASE_STORAGE_ENABLED", config.storage.enabled, "storage.enabled", projectEnvValues, ); - const edgeRuntimeEnabled = envOverrideBool( + const edgeRuntimeEnabled = yield* envOverrideBool( "SUPABASE_EDGE_RUNTIME_ENABLED", config.edge_runtime.enabled, "edge_runtime.enabled", projectEnvValues, ); - const storageS3ProtocolEnabled = envOverrideBool( + const storageS3ProtocolEnabled = yield* envOverrideBool( "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", config.storage.s3_protocol.enabled, "storage.s3_protocol.enabled", @@ -316,14 +317,14 @@ export function resolveStatusLocalState( storageSectionEnabled, edgeRuntimeEnabled, storageS3ProtocolEnabled, - }; -} + } satisfies StatusLocalState; +}); /** * The Docker-dependent, non-throwing half of status resolution: applies `excluded`, matching * each gated service by its container id ({@link statusContainerIds}) or its default Docker * image short name ({@link shortContainerImageName}) — service images are never - * user-overridable, so the default is always the one to check. Pure: every throwing concern + * user-overridable, so the default is always the one to check. Pure: every fallible concern * already ran in {@link resolveStatusLocalState}. */ export function gateStatusState( @@ -439,7 +440,7 @@ export function deprecatedStatusFieldWarning( * map. `status.handler.ts` calls the three separately so it can validate before any Docker * call and reuse the gated state for both the real and pretty-mode value maps. */ -export function statusValues( +export const statusValues = Effect.fnUntraced(function* ( config: CliConfig, containerIds: StatusContainerIds, hostname: string, @@ -449,8 +450,14 @@ export function statusValues( projectEnvValues?: Readonly>, /** `LoadedCliConfig.document` — see {@link resolveLocalConfigValues}'s doc comment. */ document?: Readonly>, -): StatusValuesResult { - const localState = resolveStatusLocalState(config, hostname, workdir, projectEnvValues, document); +) { + const localState = yield* resolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); const state = gateStatusState(localState, containerIds, excluded); return statusValuesFromState(state, overrides); -} +}); diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index 2ef704e793..f09b8ca268 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -1,6 +1,7 @@ +import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { shortContainerImageName, @@ -30,6 +31,9 @@ const NONE: ReadonlyArray = []; const NO_OVERRIDES = new Map(); const WORKDIR = "/tmp/status-values-test"; +const run = (effect: Effect.Effect) => + Effect.runSync(effect.pipe(Effect.provide(BunServices.layer))); + describe("statusValues", () => { it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { const config = baseConfig({ @@ -40,13 +44,8 @@ describe("statusValues", () => { storage: { enabled: false }, edge_runtime: { enabled: false }, }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.kong], NO_OVERRIDES, WORKDIR), ); expect(Object.keys(values)).toEqual(["DB_URL"]); expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); @@ -54,20 +53,17 @@ describe("statusValues", () => { describe("api / kong gating", () => { it("includes API_URL when api.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.API_URL).toBeDefined(); }); it("keeps the Kong URLs but omits REST/GraphQL when api.enabled is false", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.API_URL).toBeDefined(); expect(values.FUNCTIONS_URL).toBeDefined(); expect(values.MCP_URL).toBeDefined(); @@ -76,50 +72,51 @@ describe("statusValues", () => { }); it("omits API_URL when the kong container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.API_URL).toBeUndefined(); }); it("omits API_URL when the kong image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["kong"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["kong"], NO_OVERRIDES, WORKDIR), ); expect(values.API_URL).toBeUndefined(); }); it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.REST_URL).toBeUndefined(); expect(values.GRAPHQL_URL).toBeUndefined(); }); it("omits REST/GraphQL when only the rest container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.rest], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.rest], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -127,26 +124,16 @@ describe("statusValues", () => { }); it("includes REST/GraphQL when kong and postgrest are both enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); }); it("omits REST/GraphQL when the postgrest image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["postgrest"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["postgrest"], NO_OVERRIDES, WORKDIR), ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -156,43 +143,44 @@ describe("statusValues", () => { describe("functions gating", () => { it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.FUNCTIONS_URL).toBeDefined(); }); it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.edgeRuntime], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.edgeRuntime], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -200,13 +188,15 @@ describe("statusValues", () => { it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile build // alias (`edgeruntime`); the short name matched against is the former. - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["edge-runtime"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["edge-runtime"], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -214,87 +204,75 @@ describe("statusValues", () => { describe("studio / mcp gating", () => { it("includes STUDIO_URL when studio.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.STUDIO_URL).toBeDefined(); }); it("omits STUDIO_URL when studio.enabled is false", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.STUDIO_URL).toBeUndefined(); }); it("omits STUDIO_URL when the studio container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.studio], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.studio], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.STUDIO_URL).toBeUndefined(); }); it("omits STUDIO_URL when the studio image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["studio"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["studio"], NO_OVERRIDES, WORKDIR), ); expect(values.STUDIO_URL).toBeUndefined(); }); it("includes MCP_URL only when both kong and studio are enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.MCP_URL).toBeDefined(); }); it("omits MCP_URL when kong is disabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.MCP_URL).toBeUndefined(); }); it("omits MCP_URL when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.MCP_URL).toBeUndefined(); }); }); describe("auth gating", () => { it("includes all 5 auth fields when auth.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.PUBLISHABLE_KEY).toBeDefined(); expect(values.SECRET_KEY).toBeDefined(); @@ -305,7 +283,9 @@ describe("statusValues", () => { it("omits all 5 auth fields when auth.enabled is false", () => { const config = baseConfig({ auth: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); expect(values.SECRET_KEY).toBeUndefined(); expect(values.JWT_SECRET).toBeUndefined(); @@ -314,25 +294,22 @@ describe("statusValues", () => { }); it("omits all 5 auth fields when the auth container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.auth], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.auth], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); it("omits all 5 auth fields when the gotrue image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["gotrue"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["gotrue"], NO_OVERRIDES, WORKDIR), ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); @@ -340,13 +317,8 @@ describe("statusValues", () => { describe("inbucket/mailpit gating", () => { it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.MAILPIT_URL).toBeDefined(); expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); @@ -354,31 +326,30 @@ describe("statusValues", () => { it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.MAILPIT_URL).toBeUndefined(); expect(values.INBUCKET_URL).toBeUndefined(); }); it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.inbucket], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.inbucket], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.MAILPIT_URL).toBeUndefined(); }); it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["mailpit"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["mailpit"], NO_OVERRIDES, WORKDIR), ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -386,13 +357,8 @@ describe("statusValues", () => { describe("storage / s3 gating", () => { it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeDefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); @@ -402,18 +368,22 @@ describe("statusValues", () => { it("omits storage S3 fields when storage.enabled is false", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when the storage container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.storage], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.storage], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -421,20 +391,17 @@ describe("statusValues", () => { it("omits storage S3 fields when the storage-api image short name is excluded", () => { // The image repo name (`supabase/storage-api`) differs from the Dockerfile build // alias (`storage`); the short name matched against is the former. - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["storage-api"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["storage-api"], NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); }); @@ -446,30 +413,20 @@ describe("statusValues", () => { it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_API_ENABLED: "true", - }, + }), ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeDefined(); }); it("omits REST_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_API_ENABLED: "false" }, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { + SUPABASE_API_ENABLED: "false", + }), ); expect(values.REST_URL).toBeUndefined(); expect(values.API_URL).toBeDefined(); @@ -477,32 +434,20 @@ describe("statusValues", () => { it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_STUDIO_ENABLED: "true", - }, + }), ); expect(values.STUDIO_URL).toBeDefined(); }); it("includes the 5 auth fields when SUPABASE_AUTH_ENABLED overrides a disabled auth.enabled", () => { const config = baseConfig({ auth: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_AUTH_ENABLED: "true", - }, + }), ); expect(values.PUBLISHABLE_KEY).toBeDefined(); expect(values.ANON_KEY).toBeDefined(); @@ -510,91 +455,59 @@ describe("statusValues", () => { }); it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_AUTH_ENABLED: "false" }, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { + SUPABASE_AUTH_ENABLED: "false", + }), ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); it("includes MAILPIT_URL when SUPABASE_LOCAL_SMTP_ENABLED overrides a disabled local_smtp.enabled", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_LOCAL_SMTP_ENABLED: "true", - }, + }), ); expect(values.MAILPIT_URL).toBeDefined(); }); it("includes storage S3 fields when SUPABASE_STORAGE_ENABLED overrides a disabled storage.enabled", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_STORAGE_ENABLED: "true", - }, + }), ); expect(values.STORAGE_S3_URL).toBeDefined(); }); it("includes FUNCTIONS_URL when SUPABASE_EDGE_RUNTIME_ENABLED overrides a disabled edge_runtime.enabled", () => { const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_EDGE_RUNTIME_ENABLED: "true", - }, + }), ); expect(values.FUNCTIONS_URL).toBeDefined(); }); it("includes storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED overrides a disabled s3_protocol.enabled", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "true", - }, + }), ); expect(values.STORAGE_S3_URL).toBeDefined(); }); it("omits storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false overrides an enabled s3_protocol.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false" }, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, { + SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false", + }), ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -603,13 +516,8 @@ describe("statusValues", () => { describe("--override-name remapping", () => { it("remaps a field's output KEY while leaving the value unchanged", () => { const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.API_URL).toBeUndefined(); expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); @@ -620,13 +528,8 @@ describe("statusValues", () => { ["api.url", "CUSTOM_API_URL"], ["db.url", "CUSTOM_DB_URL"], ]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.CUSTOM_API_URL).toBeDefined(); expect(values.CUSTOM_DB_URL).toBeDefined(); @@ -636,13 +539,8 @@ describe("statusValues", () => { it("leaves unrelated fields at their default name when only one is overridden", () => { const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.REST_URL).toBeDefined(); }); @@ -653,13 +551,8 @@ describe("statusValues", () => { ["auth.anon_key", "CUSTOM_ANON_KEY"], ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], ]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.CUSTOM_JWT_SECRET).toBeDefined(); expect(values.CUSTOM_ANON_KEY).toBeDefined(); @@ -671,13 +564,8 @@ describe("statusValues", () => { it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); expect(values.MAILPIT_URL).toBeDefined(); @@ -688,13 +576,8 @@ describe("statusValues", () => { it("combines stopped-service exclusions with --exclude flag exclusions", () => { // Both `stopped` and `--exclude` funnel into the same `excluded` array in the handler. const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - excluded, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, excluded, NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.STUDIO_URL).toBeUndefined(); @@ -726,14 +609,9 @@ describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { ["studio", "STUDIO_URL"], ["edge-runtime", "FUNCTIONS_URL"], ] as const) { - const { values } = slimModule.statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [excluded], - NO_OVERRIDES, - WORKDIR, - ); + const { values } = yield* slimModule + .statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, [excluded], NO_OVERRIDES, WORKDIR) + .pipe(Effect.provide(BunServices.layer)); expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); } }), diff --git a/apps/cli/src/command-internal/storage-credentials.ts b/apps/cli/src/command-internal/storage-credentials.ts index 0b4158db5f..5c25c886fb 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -164,7 +164,7 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { }); /** - * Converts a thrown config-load validation error (from `envOverride*`, + * Converts a config-load validation error (from `envOverride*`, * `decryptAuthSecret`, `resolveJwtSecret`, `validateApi*`) into a tagged * `StorageConfigError`, preserving the original message. */ @@ -178,37 +178,44 @@ const toStorageConfigError = (cause: unknown) => * URL, so a stack started with an overridden port stays reachable here * instead of falling back to the raw `config.toml` value. */ -const resolveLocalApiConfig = ( +const resolveLocalApiConfig = Effect.fnUntraced(function* ( api: StorageConfigView["api"], projectEnvValues: Readonly>, -) => - Effect.try({ - try: () => { - const resolved = { - enabled: envOverrideBool( - "SUPABASE_API_ENABLED", - api.enabled, - "api.enabled", - projectEnvValues, - ), - external_url: envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, projectEnvValues), - port: envOverridePort("SUPABASE_API_PORT", api.port, "api.port", projectEnvValues), - tls: { - enabled: envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - cert_path: envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, projectEnvValues), - key_path: envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, projectEnvValues), - }, - } satisfies StorageConfigView["api"]; - validateApiPort(resolved.enabled, resolved.port); - return resolved; +) { + const resolved = { + enabled: yield* envOverrideBool( + "SUPABASE_API_ENABLED", + api.enabled, + "api.enabled", + projectEnvValues, + ), + external_url: yield* envOverride( + "SUPABASE_API_EXTERNAL_URL", + api.external_url, + projectEnvValues, + ), + port: yield* envOverridePort("SUPABASE_API_PORT", api.port, "api.port", projectEnvValues), + tls: { + enabled: yield* envOverrideBool( + "SUPABASE_API_TLS_ENABLED", + api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ), + cert_path: yield* envOverride( + "SUPABASE_API_TLS_CERT_PATH", + api.tls.cert_path, + projectEnvValues, + ), + key_path: yield* envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, projectEnvValues), }, + } satisfies StorageConfigView["api"]; + yield* Effect.try({ + try: () => validateApiPort(resolved.enabled, resolved.port), catch: toStorageConfigError, }); + return resolved; +}, Effect.mapError(toStorageConfigError)); /** * Resolves the service-role key for the local Storage gateway: @@ -223,24 +230,14 @@ const resolveLocalServiceRoleKey = Effect.fnUntraced(function* ( auth: StorageConfigView["auth"], projectEnvValues: Readonly>, ) { - const jwtSecret = yield* Effect.try({ - try: () => - resolveJwtSecret( - decryptAuthSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", auth.jwt_secret, projectEnvValues), - projectEnvValues, - ), - ), - catch: toStorageConfigError, - }); - const configuredKey = yield* Effect.try({ - try: () => - decryptAuthSecret( - envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, projectEnvValues), - projectEnvValues, - ), - catch: toStorageConfigError, - }); + const jwtSecret = yield* decryptAuthSecret( + yield* envOverride("SUPABASE_AUTH_JWT_SECRET", auth.jwt_secret, projectEnvValues), + projectEnvValues, + ).pipe(Effect.flatMap(resolveJwtSecret), Effect.mapError(toStorageConfigError)); + const configuredKey = yield* decryptAuthSecret( + yield* envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, projectEnvValues), + projectEnvValues, + ).pipe(Effect.mapError(toStorageConfigError)); return configuredKey !== undefined && configuredKey.length > 0 ? configuredKey : generateGoJwt(jwtSecret, "service_role"); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 30424ddcbd..f88703be93 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -361,7 +361,7 @@ may itself contain a `.`. - **A non-TTY script piping multiple `y`/`n` answers needs one extra leading answer for an IMPLICIT branch target.** The branch confirmation gate reads one piped stdin line just like any other prompt in this command; it runs before the per-service `keep()` prompts, so a script written for the pre-CLI-2168 prompt sequence (`api`, `db`, `auth`, ...) has every answer shifted by one when its target happens to be an inferred branch. A plain-project target, or a target named explicitly via `--project-ref`, is unaffected (no new prompt fires). - The post-run linked-project telemetry cache fill (`Effect.ensuring`, unconditional) may issue its own `GET /v1/projects/{ref}` independent of the target-detection probe above — both are best-effort/non-fatal for that fill, so a branch ref 404ing there is expected and harmless. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. -- Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced with `realpathSync` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email...content_path: resolves outside the project root ()`, before any file read. This containment check now lives centrally in `resolveEmailTemplateContentPath` (`config-validate.ts`) rather than in this command's own module (CLI-2339), and applies unconditionally to every caller of that resolver — config validation and `start`'s eager pre-Docker pass, not just `config push`. +- Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced using `fs.realPath` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email...content_path: resolves outside the project root ()`, before any file read. This containment check now lives centrally in `resolveEmailTemplateContentPath` (`config-validate.ts`) rather than in this command's own module (CLI-2339), and applies unconditionally to every caller of that resolver — config validation and `start`'s eager pre-Docker pass, not just `config push`. - **Only properties your file declares, and whose value differs from the project, are written.** Fields the API requires together ship as a group; undeclared members of that group are sent with the project's CURRENT value, read in the same run — so they do not change. Only when the read did not return a member's current value is it sent at the config schema default, and that is always disclosed (a `[group-write]` block in the confirmation output, a `forced` entry in the JSON payload, and a summary `Note:` line) — never applied silently. - **`db.ssl_enforcement`'s presence, not its decoded default, decides the gate.** `@supabase/config`'s projection recovers whether `[db.ssl_enforcement]` (and `storage.image_transformation`/`storage.s3_protocol`) were actually declared, as opposed to decoding to a schema default; an undeclared `[db.ssl_enforcement]` is treated as `disabled` — no read is needed for this any more, since row 1's single response already carries the remote value. - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) follow that same presence rule end to end — declared-but-absent is never confused with explicitly-disabled. diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.ts b/apps/cli/src/commands/config/push/push.auth-email-content.ts index 60cf929f00..534a13ad03 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.ts @@ -9,7 +9,9 @@ import type { CliConfig } from "@supabase/config"; import { Effect, FileSystem } from "effect"; import { + ConfigValidateError, emailContentPathReadErrorMessage, + fsErrorCause, resolveEmailTemplateContentPath, } from "../../../command-internal/config-validate.ts"; import { ConfigPushLoadConfigError } from "./push.errors.ts"; @@ -62,16 +64,15 @@ const readTemplateContent = Effect.fnUntraced(function* ( return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); }); -const resolveContentPath = ( - options: Parameters[0], -): Effect.Effect => - Effect.try({ - try: () => resolveEmailTemplateContentPath(options), - catch: (cause) => - new ConfigPushLoadConfigError({ +const resolveContentPath = (options: Parameters[0]) => + resolveEmailTemplateContentPath(options).pipe( + Effect.mapError((error) => { + const cause = error instanceof ConfigValidateError ? error : fsErrorCause(error); + return new ConfigPushLoadConfigError({ message: cause instanceof Error ? cause.message : String(cause), - }), - }); + }); + }), + ); /** * Loads auth email template HTML from disk for `config push`. diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 679781b858..58bd4dc5dd 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -32,7 +32,7 @@ it, and JSON `null` disables formatting without disabling safe compaction. | `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | | `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, including the explicit `--from/--to migrations` shadow) | | `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | -| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `readDbToml`/`checkDbToml` `Config.Validate` pipeline shared by every `db`/`migration` subcommand that loads config (`db dump`/`pull`/`reset`/`push`/`schema declarative generate`/`sync`, `migration up`/`down`/`squash` — documented once here rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `readDbToml`/`checkDbToml` `Config.Validate` pipeline shared by every `db`/`migration` subcommand that loads config (`db dump`/`pull`/`reset`/`push`/`schema declarative generate`/`sync`, `migration up`/`down`/`squash` — documented once here rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) — a path resolving outside it aborts before the read | | `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `migrateShadowDatabase` | | `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 70e1c19057..920b47f2b6 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -281,13 +281,16 @@ const formatConfigPath = (path: string): string => { }; /** The display key for a setting: its env var when that's what overrides it, else its config key. */ -const settingKeyLabel = ( +const settingKeyLabel = Effect.fnUntraced(function* ( setting: StackEndpointSetting, projectEnvValues: Readonly>, -): string => - [setting.envVar, setting.deprecatedEnvVar].find( - (name) => name !== undefined && envOverride(name, undefined, projectEnvValues) !== undefined, - ) ?? formatConfigPath(setting.configPath); +) { + for (const name of [setting.envVar, setting.deprecatedEnvVar]) { + if (name !== undefined && (yield* envOverride(name, undefined, projectEnvValues)) !== undefined) + return name; + } + return formatConfigPath(setting.configPath); +}); /** * One setting behind an incompatible path, reported as the JSON/stream-json error envelope's @@ -303,14 +306,14 @@ interface StructuredSettingChange { readonly editable: boolean; } -const describeSettingChange = ( +const describeSettingChange = Effect.fnUntraced(function* ( service: PlannedInstance["service"], path: string, savedCreation: ServiceCreation | undefined, requestedCreation: ServiceCreationInput | undefined, projectEnvValues: Readonly>, databaseFromServices: boolean, -): StructuredSettingChange | ReadonlyArray => { +): Effect.fn.Return> { if (service === "database" && path === "pin") { const savedVersion = databaseVersionOf(savedCreation); const requestedVersion = databaseVersionOf(requestedCreation); @@ -334,7 +337,7 @@ const describeSettingChange = ( changes.push({ service, path, - key: settingKeyLabel(stackMajorVersionSetting, projectEnvValues), + key: yield* settingKeyLabel(stackMajorVersionSetting, projectEnvValues), saved: savedMajor, requested: requestedMajor, editable: true, @@ -343,7 +346,7 @@ const describeSettingChange = ( changes.push({ service, path, - key: settingKeyLabel(stackOrioledbVersionSetting, projectEnvValues), + key: yield* settingKeyLabel(stackOrioledbVersionSetting, projectEnvValues), saved: savedOrioledb, requested: requestedOrioledb, // A catalog upgrade can retire the saved build, which the config then rejects. @@ -358,7 +361,7 @@ const describeSettingChange = ( return { service, path, - key: settingKeyLabel(setting, projectEnvValues), + key: yield* settingKeyLabel(setting, projectEnvValues), saved: endpointPortLabel(savedCreation?.endpoints, endpointName), requested: endpointPortLabel(requestedCreation?.endpoints, endpointName), editable: true, @@ -373,7 +376,7 @@ const describeSettingChange = ( requested: "changed", editable: false, }; -}; +}); /** Whether `[local.services]` names a published database release, which `[db]` settings no longer select. */ const databasePublishedBy = (source: CliConfig): boolean => { @@ -383,30 +386,30 @@ const databasePublishedBy = (source: CliConfig): boolean => { }; /** Every incompatible path across every rejected saved member, as one structured list. */ -const incompatibleSettingChanges = ( +const incompatibleSettingChanges = Effect.fnUntraced(function* ( planned: ReadonlyArray, savedConfigById: ReadonlyMap, requested: ReadonlyArray, projectEnvValues: Readonly>, databaseFromServices: boolean, -): ReadonlyArray => - planned - .filter((entry) => entry.member && entry.change === "incompatible") - .flatMap((entry) => - // Narrowed by the filter above; `Extract` isn't inferred through `.filter`. - entry.change === "incompatible" - ? entry.paths.flatMap((path) => - describeSettingChange( - entry.service, - path, - savedConfigById.get(entry.id), - requested.find((creation) => creation.service === entry.service), - projectEnvValues, - databaseFromServices, - ), - ) - : [], - ); +) { + const changes: Array = []; + for (const entry of planned) { + if (!entry.member || entry.change !== "incompatible") continue; + for (const path of entry.paths) { + const described = yield* describeSettingChange( + entry.service, + path, + savedConfigById.get(entry.id), + requested.find((creation) => creation.service === entry.service), + projectEnvValues, + databaseFromServices, + ); + changes.push(...[described].flat()); + } + } + return changes; +}); /** One deduplicated text line per distinct setting change (shared API port lines collapse to one). */ const settingChangeLines = ( @@ -442,7 +445,7 @@ const revertAdvice = (editableKeys: ReadonlyArray): string | undefined = : "Revert the settings listed to their saved values"; /** Rejects every saved member whose endpoints or database line or major the request would change. */ -const incompatibleChange = ( +const incompatibleChange = Effect.fnUntraced(function* ( planned: ReadonlyArray, savedConfigById: ReadonlyMap, requested: ReadonlyArray, @@ -450,15 +453,16 @@ const incompatibleChange = ( databaseFromServices: boolean, stackIdentity: { readonly id: string; readonly name?: string }, ownerLive: boolean, -): +): Effect.fn.Return< | { readonly error: StackCommandStartError; readonly changes: ReadonlyArray; /** The destroy invocation, when recreating the stack is among the remedies. */ readonly command: string | undefined; } - | undefined => { - const changes = incompatibleSettingChanges( + | undefined +> { + const changes = yield* incompatibleSettingChanges( planned, savedConfigById, requested, @@ -497,7 +501,7 @@ const incompatibleChange = ( suggestion, }), }; -}; +}); const selectedCreations = ( creations: ReadonlyArray, @@ -926,7 +930,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ...(target.name === undefined ? {} : { name: target.name }), }; const savedConfigById = new Map(currentStatuses.map(({ id, config: saved }) => [id, saved])); - const rejected = incompatibleChange( + const rejected = yield* incompatibleChange( planned, savedConfigById, requested, diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 556ee104b0..d26ceb6bb7 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`resolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past `` — this now (CLI-2285) applies to the functions manifest inference as well, so the two loads can never disagree about which project they resolve; also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) before it is read, aborting with `resolves outside the project root` before any other work | | `/supabase/functions//index.ts` | TypeScript | function source to deploy | | `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | | imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index e2d013c49c..028e79b7b5 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -11,7 +11,7 @@ | `/supabase/.temp/edge-runtime-version` | plain text | Read by `resolveEdgeRuntimeVersionPin()` in the handler in legacy project-state mode only (stack mode takes the tag from the locked Functions pin), before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — project dotenv (`resolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadCliConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadCliConfig` callers. A malformed config fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads. | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) before it is read, aborting with `resolves outside the project root` before any other work | | `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 54085b42ff..02270298a4 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -6,7 +6,7 @@ | -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`resolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) before it is read, aborting with `resolves outside the project root` before any other work | | `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | | `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | | `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 62df24b373..f5ac95e80b 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -22,7 +22,7 @@ and uses an automatically assigned port; `db.shadow_port` applies only to the le | Path | Format | When | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `readDbToml` for shadow port/password/vault/baseline | -| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `readDbToml` `Config.Validate` pipeline shared by `migration up`/`down` and every `db` subcommand that loads config (`dump`/`pull`/`reset`/`diff`/`push`/`schema declarative generate`/`sync` — documented on `db diff`'s `SIDE_EFFECTS.md` and here, rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `readDbToml` `Config.Validate` pipeline shared by `migration up`/`down` and every `db` subcommand that loads config (`dump`/`pull`/`reset`/`diff`/`push`/`schema declarative generate`/`sync` — documented on `db diff`'s `SIDE_EFFECTS.md` and here, rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) — a path resolving outside it aborts before the read | | `/supabase/migrations/` | directory | always | | `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | | `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 49228c549f..3ea7a7d4fa 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -5,6 +5,7 @@ import { block } from "../../command-internal/command-permissions/model.ts"; import { Output } from "../../shared/output/output.service.ts"; import { notebooksMachineOutputRequested } from "./notebooks.output.ts"; import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; +import { fsErrorCause } from "../../command-internal/config-validate.ts"; import { canonicalPathForContainment, isPathContainedInRoot, @@ -130,18 +131,20 @@ const notebookFilePath = Effect.fnUntraced(function* (workdir: string, name: str * (`resolveEmailTemplateContentPath`). */ const ensureNotebookPathContained = Effect.fnUntraced(function* (workdir: string, path: string) { - const contained = yield* Effect.try({ - try: () => - isPathContainedInRoot( - canonicalPathForContainment(workdir), - canonicalPathForContainment(path), - ), - catch: (cause) => - new NotebookFileError({ + const pathService = yield* Path.Path; + const contained = yield* Effect.all([ + canonicalPathForContainment(workdir), + canonicalPathForContainment(path), + ]).pipe( + Effect.map(([root, candidate]) => isPathContainedInRoot(pathService, root, candidate)), + Effect.mapError((error) => { + const cause = fsErrorCause(error); + return new NotebookFileError({ detail: `Cannot resolve ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, suggestion: "Check the notebooks path is a readable directory.", - }), - }); + }); + }), + ); if (!contained) { // Echoes the declared path rather than what it resolves to, so a hostile checkout cannot // use the error to probe where its symlink lands. diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts index a2dfc007fd..ce8cae7491 100644 --- a/apps/cli/src/commands/services/services-local-stack.ts +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -28,43 +28,38 @@ export const stackServiceVersions = Effect.fn("services.stackServiceVersions")(f let selection: ArtifactSelection = defaultArtifactSelection(); if (Result.isFailure(context)) configError = context.failure; else { - const resolved = yield* Effect.try({ - try: () => { - const value = envOverrideMajorVersion( - context.success.config.db.major_version, + const resolved = yield* Effect.gen(function* () { + const value = yield* envOverrideMajorVersion( + context.success.config.db.major_version, + context.success.projectEnvValues, + ).pipe(Effect.mapError((cause) => cause.message)); + if (value !== 15 && value !== 17) + return yield* Effect.fail(`unsupported PostgreSQL major version: ${value}`); + const db = { + major_version: value, + orioledb_version: yield* envOverrideOrioleDBVersion( + context.success.config.db.orioledb_version, context.success.projectEnvValues, - ); - if (value !== 15 && value !== 17) - throw new Error(`unsupported PostgreSQL major version: ${value}`); - const db = { - major_version: value, - orioledb_version: envOverrideOrioleDBVersion( - context.success.config.db.orioledb_version, - context.success.projectEnvValues, + ), + }; + // A listing never resolves: it shows the locked pins, else the bundled ones. + return yield* readSelectionOrBundled({ + projectRoot: workdir, + majorVersion: db.major_version, + orioledbVersion: db.orioledb_version, + services: context.success.config.local?.services, + }).pipe( + Effect.catchTag("LockResolutionError", (error) => + Effect.sync(() => { + configError = error.message; + }).pipe( + Effect.andThen(stackArtifactSelection(db)), + Effect.mapError((cause) => new StackConfigError({ message: cause.message })), ), - }; - // A listing never resolves: it shows the locked pins, else the bundled ones. - return readSelectionOrBundled({ - projectRoot: workdir, - majorVersion: db.major_version, - orioledbVersion: db.orioledb_version, - services: context.success.config.local?.services, - }).pipe( - Effect.catchTag("LockResolutionError", (error) => - Effect.sync(() => { - configError = error.message; - }).pipe( - Effect.andThen(stackArtifactSelection(db)), - Effect.mapError((cause) => new StackConfigError({ message: cause.message })), - ), - ), - ); - }, - catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), - }).pipe( - Effect.flatMap((selected) => Effect.mapError(selected, (error) => error.message)), - Effect.result, - ); + ), + Effect.mapError((error) => error.message), + ); + }).pipe(Effect.result); if (Result.isFailure(resolved)) configError = resolved.failure; else selection = resolved.success; } diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 0fa868828f..643804de2b 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -103,7 +103,7 @@ command. | project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | | `auth.signing_keys_path` file | JSON | when configured | | `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | -| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured — for every configured template plus every ENABLED notification, resolved paths are CONFINED to the project root (symlinks dereferenced with `realpathSync`) and read-verified (bytes discarded) in an eager pre-Docker pass, REGARDLESS of `auth.enabled` (Kong mounts these unconditionally — see Notes) | +| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured — for every configured template plus every ENABLED notification, resolved paths are CONFINED to the project root (symlinks dereferenced using `fs.realPath`) and read-verified (bytes discarded) in an eager pre-Docker pass, REGARDLESS of `auth.enabled` (Kong mounts these unconditionally — see Notes) | | GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | | `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | @@ -379,7 +379,7 @@ prose, not structured data. container, whose own env-file/multiline-env-script staging is unaffected by that change. - Existing local values declared under a Function import map's `scopes` are mounted read-only into Edge Runtime, and into the Studio container that shares the same resolved Function bind mounts, even when they resolve outside the nearest Git root. The Edge Runtime container additionally prunes its aggregated bind list before `docker create` — a bind already supplied verbatim by a same-mode parent bind is dropped so the bootstrap `docker cp` never sees a file bind nested inside a read-only parent (supabase/supabase#50088) — while Studio keeps the full unpruned set (its bring-up performs no `docker cp`). The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. Edge Runtime bring-up prints a `WARN` naming each distinct out-of-root host path once; Studio's bind resolution stays silent, so with Edge Runtime excluded (`-x edge-runtime`) the mounts still reach Studio and no warning is printed. Missing targets retain Edge Runtime startup's existing skip behavior. This is NOT the same permissiveness as email template `content_path` below — a `scopes` value is an explicit opt-in mechanism, mounted `:ro`, with containment enforced on the upload side elsewhere (`functions deploy`), whereas `content_path` has no opt-in at all and is enforced at resolution time, mounted `:rw,z`. -- **Auth email `content_path` project-root containment AND readability apply here regardless of `auth.enabled`.** Kong's mount set (`resolveKongEmailTemplateMounts`, `start.handler.ts`) covers every configured `[auth.email.template.*]` entry, and every `enabled = true` `[auth.email.notification.*]` entry, unconditionally — Kong is the stack's mandatory gateway, independent of whether GoTrue itself is started. `start.handler.ts` resolves, confines (`resolveEmailTemplateContentPath`; symlinks dereferenced with `realpathSync` before the check), AND read-verifies (a discarded `FileSystem.readFileString`) that same set in one eager pass before any Docker work, in addition to the `auth.enabled`-gated read `resolveLocalConfigValues`'s own validation performs; the resulting resolved path is threaded straight into Kong's bind mount (`buildKongEmailTemplateBind` in `kong.service.ts`, which no longer re-resolves anything) rather than re-derived later, right before Kong's own `docker create`. Each template is bound `rw,z`: when the container runtime applies SELinux labels (enforcing or permissive mode) it relabels the mounted host file in place with the shared container context so Kong can read it under enforcement — a persistent change to the file's security label that survives `supabase stop` — and the option is a no-op where the runtime has no SELinux support. A path that resolves outside the project root aborts the run with `Invalid config for auth.email.
..content_path: resolves outside the project root ()`; a resolved, in-root path that cannot be read as a regular file aborts with `Invalid config for auth.email.
..content_path: ` — both before a single container is created, and both regardless of `auth.enabled` (closing the gap where a missing `content_path` would otherwise reach an unconditional Kong bind mount and the root-privileged Docker daemon would silently create a directory there). +- **Auth email `content_path` project-root containment AND readability apply here regardless of `auth.enabled`.** Kong's mount set (`resolveKongEmailTemplateMounts`, `start.handler.ts`) covers every configured `[auth.email.template.*]` entry, and every `enabled = true` `[auth.email.notification.*]` entry, unconditionally — Kong is the stack's mandatory gateway, independent of whether GoTrue itself is started. `start.handler.ts` resolves, confines (`resolveEmailTemplateContentPath`; symlinks dereferenced using `fs.realPath` before the check), AND read-verifies (a discarded `FileSystem.readFileString`) that same set in one eager pass before any Docker work, in addition to the `auth.enabled`-gated read `resolveLocalConfigValues`'s own validation performs; the resulting resolved path is threaded straight into Kong's bind mount (`buildKongEmailTemplateBind` in `kong.service.ts`, which no longer re-resolves anything) rather than re-derived later, right before Kong's own `docker create`. Each template is bound `rw,z`: when the container runtime applies SELinux labels (enforcing or permissive mode) it relabels the mounted host file in place with the shared container context so Kong can read it under enforcement — a persistent change to the file's security label that survives `supabase stop` — and the option is a no-op where the runtime has no SELinux support. A path that resolves outside the project root aborts the run with `Invalid config for auth.email.
..content_path: resolves outside the project root ()`; a resolved, in-root path that cannot be read as a regular file aborts with `Invalid config for auth.email.
..content_path: ` — both before a single container is created, and both regardless of `auth.enabled` (closing the gap where a missing `content_path` would otherwise reach an unconditional Kong bind mount and the root-privileged Docker daemon would silently create a directory there). - Docker status `created` is not considered a recoverable stopped stack: the container and named volume are preserved because the volume may not have completed its first database initialization, and `start` reports the existing not-running status instead. diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index 30ddbd6fea..dd3438d9f0 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -1,5 +1,5 @@ import type { CliConfig } from "@supabase/config"; -import { Predicate } from "effect"; +import { Effect, Predicate } from "effect"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; import type { @@ -53,41 +53,41 @@ function asRecord(value: unknown): Record | undefined { * Evaluates every excludable start gate in one pass. Postgres and Edge Runtime are handled * separately by the caller (see this module's header). */ -export function resolveStartGates(inputs: StartGateInputs): StartGates { +export const resolveStartGates = Effect.fnUntraced(function* (inputs: StartGateInputs) { const { config, projectEnvValues, excludedKeys, document } = inputs; const isExcluded = (key: string) => excludedKeys.has(key); - const analyticsEnabled = envOverrideBool( + const analyticsEnabled = yield* envOverrideBool( "SUPABASE_ANALYTICS_ENABLED", config.analytics.enabled, "analytics.enabled", projectEnvValues, ); - const apiEnabled = envOverrideBool( + const apiEnabled = yield* envOverrideBool( "SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues, ); - const authEnabled = envOverrideBool( + const authEnabled = yield* envOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, "auth.enabled", projectEnvValues, ); - const inbucketEnabled = envOverrideBool( + const inbucketEnabled = yield* envOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", config.local_smtp.enabled, "local_smtp.enabled", projectEnvValues, ); - const realtimeEnabled = envOverrideBool( + const realtimeEnabled = yield* envOverrideBool( "SUPABASE_REALTIME_ENABLED", config.realtime.enabled, "realtime.enabled", projectEnvValues, ); - const storageEnabled = envOverrideBool( + const storageEnabled = yield* envOverrideBool( "SUPABASE_STORAGE_ENABLED", config.storage.enabled, "storage.enabled", @@ -101,26 +101,26 @@ export function resolveStartGates(inputs: StartGateInputs): StartGates { const configuredImageTransformationEnabled = config.storage.image_transformation?.enabled ?? false; const imageTransformationEnabled = imageTransformationSectionPresent - ? envOverrideBool( + ? yield* envOverrideBool( "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", configuredImageTransformationEnabled, "storage.image_transformation.enabled", projectEnvValues, ) : configuredImageTransformationEnabled; - const studioEnabled = envOverrideBool( + const studioEnabled = yield* envOverrideBool( "SUPABASE_STUDIO_ENABLED", config.studio.enabled, "studio.enabled", projectEnvValues, ); - const poolerEnabled = envOverrideBool( + const poolerEnabled = yield* envOverrideBool( "SUPABASE_DB_POOLER_ENABLED", config.db.pooler.enabled, "db.pooler.enabled", projectEnvValues, ); - const edgeRuntimeEnabled = envOverrideBool( + const edgeRuntimeEnabled = yield* envOverrideBool( "SUPABASE_EDGE_RUNTIME_ENABLED", config.edge_runtime.enabled, "edge_runtime.enabled", @@ -143,8 +143,8 @@ export function resolveStartGates(inputs: StartGateInputs): StartGates { studio: studioEnabled && !isExcluded("studio"), supavisor: poolerEnabled && !isExcluded("supavisor"), edgeRuntime: edgeRuntimeEnabled && !isExcluded("edge-runtime"), - }; -} + } satisfies StartGates; +}); const GATE_KEY_BY_SERVICE: Readonly> = { logflare: "logflare", diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 5d3c6133f0..c800f3bf1d 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -33,7 +33,9 @@ import { aqua, yellow } from "../../command-internal/colors.ts"; import { apiTlsCertReadErrorMessage, apiTlsKeyReadErrorMessage, + ConfigValidateError, emailContentPathReadErrorMessage, + fsErrorCause, resolveApiTlsPath, resolveEmailTemplateContentPath, } from "../../command-internal/config-validate.ts"; @@ -191,33 +193,27 @@ function asRecord(value: unknown): Record | undefined { return Predicate.isObject(value) ? value : undefined; } -function tryStartConfig(thunk: () => T): Effect.Effect { - return Effect.try({ - try: thunk, - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), +const startConfigError = (cause: unknown) => + new StartInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }); + +const configOverrideError = (dottedFieldPath: string) => (cause: unknown) => + new StartInvalidConfigError({ + message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, }); -} /** - * Wraps a synchronous `envOverride*` config read that throws on a malformed value into a typed + * Maps an `envOverride*` config read's malformed-value failure into a typed * `StartInvalidConfigError`, so a bad override fails the command through the normal error path * instead of surfacing as an untyped Effect defect. */ -function wrapConfigOverride( - dottedFieldPath: string, - thunk: () => T, -): Effect.Effect { - return Effect.try({ - try: thunk, - catch: (cause) => - new StartInvalidConfigError({ - message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); -} +const wrapConfigOverride = (dottedFieldPath: string, effect: Effect.Effect) => + effect.pipe(Effect.mapError(configOverrideError(dottedFieldPath))); + +/** {@link wrapConfigOverride} for a synchronous parser that throws on a malformed value. */ +const wrapConfigParse = (dottedFieldPath: string, thunk: () => T) => + Effect.try({ try: thunk, catch: configOverrideError(dottedFieldPath) }); /** * Every value {@link buildGotrueContainerSpec} needs from `config`/`values`, minus @@ -230,20 +226,20 @@ function wrapConfigOverride( * or auth is disabled. */ -function resolveGotrueEnvInput(params: { +const resolveGotrueEnvInput = Effect.fnUntraced(function* (params: { readonly context: LocalProjectContext; readonly values: LocalConfigValues; readonly workdir: string; readonly kongContainerName: string; readonly mailpitContainerName: string; readonly resolvedEmail: ResolvedAuthEmail; -}): Omit { +}) { const { context, values, workdir, kongContainerName, mailpitContainerName, resolvedEmail } = params; const { config, projectEnvValues, loaded } = context; const document = loaded?.document; - const inbucketEnabled = envOverrideBool( + const inbucketEnabled = yield* envOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", config.local_smtp.enabled, "local_smtp.enabled", @@ -253,7 +249,7 @@ function resolveGotrueEnvInput(params: { // when the key is merely absent from the TOML table, silently falling back to Mailpit even when // a real SMTP server is configured. `resolveAuthEmailSmtp` resolves this correctly off the raw // document. - const resolvedSmtp = resolveAuthEmailSmtp(asRecord(document?.["auth"]), projectEnvValues); + const resolvedSmtp = yield* resolveAuthEmailSmtp(asRecord(document?.["auth"]), projectEnvValues); const smtp = resolvedSmtp?.enabled === true ? { @@ -267,12 +263,12 @@ function resolveGotrueEnvInput(params: { : undefined; // Same override gap as `inbucketEnabled` above; these are value-typed fields, so no // raw-document presence gate is needed. - const mailpitAdminEmail = envOverride( + const mailpitAdminEmail = yield* envOverride( "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", config.local_smtp.admin_email, projectEnvValues, ); - const mailpitSenderName = envOverride( + const mailpitSenderName = yield* envOverride( "SUPABASE_LOCAL_SMTP_SENDER_NAME", config.local_smtp.sender_name, projectEnvValues, @@ -286,13 +282,16 @@ function resolveGotrueEnvInput(params: { } : undefined; - const { passkeyEnabled, webauthn } = resolveGotruePasskeyWebauthn(document, projectEnvValues); - const externalProviders = resolveAuthExternalProviders( + const { passkeyEnabled, webauthn } = yield* resolveGotruePasskeyWebauthn( + document, + projectEnvValues, + ); + const externalProviders = yield* resolveAuthExternalProviders( asRecord(document?.["auth"]), config.auth.external, projectEnvValues, ); - const authExternalUrl = resolveAuthExternalUrl(document, projectEnvValues); + const authExternalUrl = yield* resolveAuthExternalUrl(document, projectEnvValues); return { apiUrl: values.apiUrl, @@ -313,14 +312,18 @@ function resolveGotrueEnvInput(params: { kongContainerName, smtp, mailpit, - sms: resolveAuthSms(asRecord(document?.["auth"]), config.auth.sms, projectEnvValues), - sessions: resolveGotrueSessions(config.auth.sessions, projectEnvValues), - mfa: resolveAuthMfa(config.auth.mfa, projectEnvValues), - rateLimit: resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), - web3: resolveGotrueWeb3(config.auth.web3, projectEnvValues), - oauthServer: resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), - hooks: resolveAuthHooks(asRecord(document?.["auth"]), config.auth.hook, projectEnvValues), - captcha: resolveAuthCaptcha( + sms: yield* resolveAuthSms(asRecord(document?.["auth"]), config.auth.sms, projectEnvValues), + sessions: yield* resolveGotrueSessions(config.auth.sessions, projectEnvValues), + mfa: yield* resolveAuthMfa(config.auth.mfa, projectEnvValues), + rateLimit: yield* resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), + web3: yield* resolveGotrueWeb3(config.auth.web3, projectEnvValues), + oauthServer: yield* resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), + hooks: yield* resolveAuthHooks( + asRecord(document?.["auth"]), + config.auth.hook, + projectEnvValues, + ), + captcha: yield* resolveAuthCaptcha( asRecord(document?.["auth"]), config.auth.captcha, projectEnvValues, @@ -328,9 +331,9 @@ function resolveGotrueEnvInput(params: { passkeyEnabled, webauthn, externalProviders, - signingKeys: resolveConfiguredSigningKeys(config, workdir, projectEnvValues), - }; -} + signingKeys: yield* resolveConfiguredSigningKeys(config, workdir, projectEnvValues), + } satisfies Omit; +}); /** * Existence/readability check for one already-resolved `content_path`. Without it, a @@ -378,14 +381,16 @@ const resolveKongEmailTemplateMounts = Effect.fn("start.resolveEmailTemplateMoun name: string, contentPath: string, ) => - tryStartConfig(() => - resolveEmailTemplateContentPath({ - section, - name, - contentPath, - contentPresent: false, - base: workdir, - }), + resolveEmailTemplateContentPath({ + section, + name, + contentPath, + contentPresent: false, + base: workdir, + }).pipe( + Effect.mapError((error) => + startConfigError(error instanceof ConfigValidateError ? error : fsErrorCause(error)), + ), ); const mounts: Array = []; for (const [id, template] of Object.entries(email.template)) { @@ -454,28 +459,24 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { cliSettings.workdir, (message) => new StartConfigLoadError({ message }), ); - const values = yield* tryStartConfig(() => - resolveLocalConfigValues( - context.config, - context.hostname, - cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - ); + const values = yield* resolveLocalConfigValues( + context.config, + context.hostname, + cliSettings.workdir, + context.projectEnvValues, + context.loaded?.document, + ).pipe(Effect.mapError(startConfigError)); const { config, projectId, projectEnvValues } = context; // Resolved here (project `.env` aware) so it can be threaded through to `startDatabase`'s // own `setup.experimental` below. const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); // Single source resolved once, fed to both Kong's template mounts and GoTrue's env builder — // see {@link resolveAuthEmail}'s doc comment. - const resolvedEmail = yield* tryStartConfig(() => - resolveAuthEmail( - config.auth.email, - asRecord(context.loaded?.document?.["auth"]), - projectEnvValues, - ), - ); + const resolvedEmail = yield* resolveAuthEmail( + config.auth.email, + asRecord(context.loaded?.document?.["auth"]), + projectEnvValues, + ).pipe(Effect.mapError(startConfigError)); // Kong mounts every configured template and every enabled notification's `content_path`, // regardless of `auth.enabled` — see {@link resolveKongEmailTemplateMounts} for why this // resolves and verifies each path once, here, rather than re-deriving it before Kong's @@ -488,23 +489,21 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Duration fields (Go duration syntax) are otherwise only parsed inside GoTrue's own env // builder, which never runs when auth is disabled or `gotrue` is excluded — so a malformed // value must be validated eagerly here or it would be silently accepted. - const gotrueSessionsForValidation = resolveGotrueSessions( + const gotrueSessionsForValidation = yield* resolveGotrueSessions( config.auth.sessions, projectEnvValues, ); - yield* wrapConfigOverride("auth.email.max_frequency", () => + yield* wrapConfigParse("auth.email.max_frequency", () => parseGoDuration(resolvedEmail.max_frequency), ); // `resolveLocalConfigValues`'s own SMS validation only runs when auth is enabled, so this is // the only place a malformed `auth.sms.*` override is caught when auth is disabled. - const smsForValidation = yield* tryStartConfig(() => - resolveAuthSms( - asRecord(context.loaded?.document?.["auth"]), - config.auth.sms, - projectEnvValues, - ), - ); - yield* wrapConfigOverride("auth.sms.max_frequency", () => + const smsForValidation = yield* resolveAuthSms( + asRecord(context.loaded?.document?.["auth"]), + config.auth.sms, + projectEnvValues, + ).pipe(Effect.mapError(startConfigError)); + yield* wrapConfigParse("auth.sms.max_frequency", () => parseGoDuration(smsForValidation.max_frequency), ); // `resolveAuthSms` already downgrades `enable_signup` when no provider is enabled; this only @@ -515,47 +514,53 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { !smsForValidation.messagebird.enabled && !smsForValidation.textlocal.enabled && !smsForValidation.vonage.enabled && - envOverrideBool( + (yield* envOverrideBool( "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", config.auth.sms.enable_signup, "auth.sms.enable_signup", projectEnvValues, - ) + ).pipe(Effect.orDie)) ) { yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); } const sessionsTimebox = gotrueSessionsForValidation?.timebox; if (sessionsTimebox !== undefined) { - yield* wrapConfigOverride("auth.sessions.timebox", () => parseGoDuration(sessionsTimebox)); + yield* wrapConfigParse("auth.sessions.timebox", () => parseGoDuration(sessionsTimebox)); } const sessionsInactivityTimeout = gotrueSessionsForValidation?.inactivity_timeout; if (sessionsInactivityTimeout !== undefined) { - yield* wrapConfigOverride("auth.sessions.inactivity_timeout", () => + yield* wrapConfigParse("auth.sessions.inactivity_timeout", () => parseGoDuration(sessionsInactivityTimeout), ); } - yield* wrapConfigOverride("auth.mfa.phone.max_frequency", () => - parseGoDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), + const mfaForValidation = yield* wrapConfigOverride( + "auth.mfa.phone.max_frequency", + resolveAuthMfa(config.auth.mfa, projectEnvValues), + ); + yield* wrapConfigParse("auth.mfa.phone.max_frequency", () => + parseGoDuration(mfaForValidation.phone.max_frequency), ); // These GoTrue overrides must validate unconditionally too, regardless of - // `auth.enabled`/`--exclude gotrue`. The resolvers already throw internally on a bad + // `auth.enabled`/`--exclude gotrue`. The resolvers already fail internally on a bad // override, so calling each once here is simpler than re-deriving every field individually. - yield* wrapConfigOverride("auth.rate_limit", () => + yield* wrapConfigOverride( + "auth.rate_limit", resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), ); - yield* wrapConfigOverride("auth.web3", () => - resolveGotrueWeb3(config.auth.web3, projectEnvValues), - ); - yield* wrapConfigOverride("auth.oauth_server", () => + yield* wrapConfigOverride("auth.web3", resolveGotrueWeb3(config.auth.web3, projectEnvValues)); + yield* wrapConfigOverride( + "auth.oauth_server", resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), ); // Same gap for the raw (unmodeled by `@supabase/config`) `auth.passkey`/`auth.webauthn`/ // `auth.external.` booleans, which are otherwise only reached once auth is enabled and // gotrue isn't excluded. - yield* wrapConfigOverride("auth.passkey", () => + yield* wrapConfigOverride( + "auth.passkey", resolveGotruePasskeyWebauthn(context.loaded?.document, projectEnvValues), ); - yield* wrapConfigOverride("auth.external", () => + yield* wrapConfigOverride( + "auth.external", resolveAuthExternalProviders( asRecord(context.loaded?.document?.["auth"]), config.auth.external, @@ -564,7 +569,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // Same gap for `auth.third_party..*` — `resolveThirdPartyProviders` is otherwise // never called by this handler at all, so a malformed override would never fail the command. - yield* wrapConfigOverride("auth.third_party", () => + yield* wrapConfigOverride( + "auth.third_party", resolveThirdPartyProviders(config.auth.third_party, projectEnvValues), ); // `[functions..env]` has no supported meaning for `start`, so any key here must be @@ -599,21 +605,21 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { excluded: ReadonlyArray, precomputedLocal?: LocalConfigValues, ) { - const localState = yield* Effect.try({ - try: () => - resolveStatusLocalState( - context.config, - context.hostname, - cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, - precomputedLocal, - ), - catch: (cause) => - new StatusInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const localState = yield* resolveStatusLocalState( + context.config, + context.hostname, + cliSettings.workdir, + context.projectEnvValues, + context.loaded?.document, + precomputedLocal, + ).pipe( + Effect.mapError( + (cause) => + new StatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ); const containerIds = statusContainerIds(projectId); const state = gateStatusState(localState, containerIds, excluded); return statusValuesFromState(state, new Map()); @@ -707,16 +713,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // 4. No update-suggestion check: `start` has no Management API dependency by design. - // 5. Gate evaluation — see `start.gates.ts`. Wrapped because `envOverrideBool` throws - // synchronously on an unparsable value, and this handler surfaces that as a typed error. - const gates = yield* tryStartConfig(() => - resolveStartGates({ - config, - projectEnvValues, - excludedKeys, - document: context.loaded?.document, - }), - ); + // 5. Gate evaluation — see `start.gates.ts`. Mapped because `envOverrideBool` fails on + // an unparsable value, and this handler surfaces that as a `StartInvalidConfigError`. + const gates = yield* resolveStartGates({ + config, + projectEnvValues, + excludedKeys, + document: context.loaded?.document, + }).pipe(Effect.mapError(startConfigError)); // 6. JWKS resolution runs unconditionally, before any image pull, regardless of which // services end up enabled. @@ -729,7 +733,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // The `edge_runtime.deno_version` -> image switch is start-only (no `db start` equivalent), // so it's resolved here rather than inside the shared bootstrap-config derivation below. - const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + const denoVersion = yield* envOverrideDenoVersion( + config.edge_runtime.deno_version, + projectEnvValues, + ).pipe(Effect.orDie); // Every field the fresh-DB bootstrap needs, shared with `db start`'s own native bootstrap — // see `bootstrap-config.ts`'s header for why this is one shared derivation. @@ -875,13 +882,15 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // The TLS cert/key disk read is gated on `api.enabled` itself, not just `api.tls.enabled`. // Resolved separately from `gates.postgrest`'s own `apiEnabled`, which is additionally // combined with `--exclude postgrest` — a distinction config validation has no equivalent for. - const apiEnabled = yield* wrapConfigOverride("api.enabled", () => + const apiEnabled = yield* wrapConfigOverride( + "api.enabled", envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues), ); // The post-bring-up health-probe CA-trust lookup needs this same env-overridden value, not // the raw `config.api.tls.enabled` — both the trust pool and its target URL must read one // source of truth. - const apiTlsEnabled = yield* wrapConfigOverride("api.tls.enabled", () => + const apiTlsEnabled = yield* wrapConfigOverride( + "api.tls.enabled", envOverrideBool( "SUPABASE_API_TLS_ENABLED", config.api.tls.enabled, @@ -891,12 +900,12 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // Same override gap as `apiTlsEnabled` above: the env overrides must apply before reading // the cert/key paths from disk. - const apiTlsCertPath = envOverride( + const apiTlsCertPath = yield* envOverride( "SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues, ); - const apiTlsKeyPath = envOverride( + const apiTlsKeyPath = yield* envOverride( "SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues, @@ -914,7 +923,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { apiTlsKeyPath.length > 0 ) { tlsCertContent = yield* fs - .readFileString(resolveApiTlsPath(cliSettings.workdir, apiTlsCertPath)) + .readFileString(resolveApiTlsPath(path, cliSettings.workdir, apiTlsCertPath)) .pipe( Effect.mapError( (cause) => @@ -924,7 +933,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ), ); tlsKeyContent = yield* fs - .readFileString(resolveApiTlsPath(cliSettings.workdir, apiTlsKeyPath)) + .readFileString(resolveApiTlsPath(path, cliSettings.workdir, apiTlsKeyPath)) .pipe( Effect.mapError( (cause) => @@ -937,7 +946,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Same gap for `storage.vector.enabled` — both the Storage container and `seedBucketsRun`'s // config splice further down must see the same already-overridden value. - const storageVectorEnabled = yield* wrapConfigOverride("storage.vector.enabled", () => + const storageVectorEnabled = yield* wrapConfigOverride( + "storage.vector.enabled", envOverrideBool( "SUPABASE_STORAGE_VECTOR_ENABLED", config.storage.vector.enabled, @@ -947,7 +957,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // Same gap for `storage.s3_protocol.enabled`: the Storage spec builder only parsed this // lazily, so a malformed override was silently accepted whenever Storage is excluded/disabled. - const storageS3ProtocolEnabled = yield* wrapConfigOverride("storage.s3_protocol.enabled", () => + const storageS3ProtocolEnabled = yield* wrapConfigOverride( + "storage.s3_protocol.enabled", envOverrideBool( "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", config.storage.s3_protocol.enabled, @@ -958,7 +969,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Same gap for `storage.analytics.enabled`. `start` never reads this field itself (only // `seed buckets --linked` does), so it's validated here purely for fail-fast parity and the // result discarded. - yield* wrapConfigOverride("storage.analytics.enabled", () => + yield* wrapConfigOverride( + "storage.analytics.enabled", envOverrideBool( "SUPABASE_STORAGE_ANALYTICS_ENABLED", config.storage.analytics.enabled, @@ -968,7 +980,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // These plain `uint` fields must validate unconditionally too. `start` never reads them // itself (only `config push`/`pull` do), so they're validated purely for fail-fast parity. - yield* wrapConfigOverride("storage.analytics.max_namespaces", () => + yield* wrapConfigOverride( + "storage.analytics.max_namespaces", envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", "storage.analytics.max_namespaces", @@ -976,7 +989,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - yield* wrapConfigOverride("storage.analytics.max_tables", () => + yield* wrapConfigOverride( + "storage.analytics.max_tables", envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", "storage.analytics.max_tables", @@ -984,7 +998,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - yield* wrapConfigOverride("storage.analytics.max_catalogs", () => + yield* wrapConfigOverride( + "storage.analytics.max_catalogs", envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", "storage.analytics.max_catalogs", @@ -992,7 +1007,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - yield* wrapConfigOverride("storage.vector.max_buckets", () => + yield* wrapConfigOverride( + "storage.vector.max_buckets", envOverrideUint( "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", "storage.vector.max_buckets", @@ -1000,7 +1016,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - yield* wrapConfigOverride("storage.vector.max_indexes", () => + yield* wrapConfigOverride( + "storage.vector.max_indexes", envOverrideUint( "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", "storage.vector.max_indexes", @@ -1011,10 +1028,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Same gap for `api.schemas`/`api.extra_search_path`/`api.max_rows` — both PostgREST's own // container and Studio's copy of the same `PGRST_DB_*` env must see the overridden values. - const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, projectEnvValues); + const apiSchemasOverride = yield* envOverride( + "SUPABASE_API_SCHEMAS", + undefined, + projectEnvValues, + ); const apiSchemas = apiSchemasOverride !== undefined ? apiSchemasOverride.split(",") : config.api.schemas; - const apiExtraSearchPathOverride = envOverride( + const apiExtraSearchPathOverride = yield* envOverride( "SUPABASE_API_EXTRA_SEARCH_PATH", undefined, projectEnvValues, @@ -1023,13 +1044,15 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { apiExtraSearchPathOverride !== undefined ? apiExtraSearchPathOverride.split(",") : config.api.extra_search_path; - const apiMaxRows = yield* wrapConfigOverride("api.max_rows", () => + const apiMaxRows = yield* wrapConfigOverride( + "api.max_rows", envOverrideApiMaxRows(config.api.max_rows, projectEnvValues), ); // Same gap for Mailpit's three ports. `smtp_port`/`pop3_port` have no TOML default, so `?? 0` // here preserves the "unconfigured" signal `mailpit.service.ts`'s `!== 0` publish guard checks. - const mailpitPort = yield* wrapConfigOverride("local_smtp.port", () => + const mailpitPort = yield* wrapConfigOverride( + "local_smtp.port", envOverridePort( "SUPABASE_LOCAL_SMTP_PORT", config.local_smtp.port, @@ -1037,7 +1060,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - const mailpitSmtpPort = yield* wrapConfigOverride("local_smtp.smtp_port", () => + const mailpitSmtpPort = yield* wrapConfigOverride( + "local_smtp.smtp_port", envOverridePort( "SUPABASE_LOCAL_SMTP_SMTP_PORT", config.local_smtp.smtp_port ?? 0, @@ -1045,7 +1069,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - const mailpitPop3Port = yield* wrapConfigOverride("local_smtp.pop3_port", () => + const mailpitPop3Port = yield* wrapConfigOverride( + "local_smtp.pop3_port", envOverridePort( "SUPABASE_LOCAL_SMTP_POP3_PORT", config.local_smtp.pop3_port ?? 0, @@ -1056,7 +1081,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Same gap for Logflare's port — `SUPABASE_ANALYTICS_PORT` must apply // before building Logflare's host port binding. - const analyticsPort = yield* wrapConfigOverride("analytics.port", () => + const analyticsPort = yield* wrapConfigOverride( + "analytics.port", envOverridePort( "SUPABASE_ANALYTICS_PORT", config.analytics.port, @@ -1066,7 +1092,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // Same gap for Logflare's deprecated `analytics.vector_port`: nothing downstream reads the // resolved value, but a malformed override must still fail before any Docker work. - yield* wrapConfigOverride("analytics.vector_port", () => + yield* wrapConfigOverride( + "analytics.vector_port", envOverridePort( "SUPABASE_ANALYTICS_VECTOR_PORT", config.analytics.vector_port ?? 0, @@ -1078,7 +1105,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Same gap for Supavisor's pooler fields — `pool_mode` specifically decides the published // host port (5432 session vs 6543 transaction). Wrapped via `wrapConfigOverride` so a bad // value fails as a typed error instead of an untyped Effect defect. - const poolerPort = yield* wrapConfigOverride("db.pooler.port", () => + const poolerPort = yield* wrapConfigOverride( + "db.pooler.port", envOverridePort( "SUPABASE_DB_POOLER_PORT", config.db.pooler.port, @@ -1086,13 +1114,16 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - const poolMode = yield* wrapConfigOverride("db.pooler.pool_mode", () => + const poolMode = yield* wrapConfigOverride( + "db.pooler.pool_mode", envOverridePoolMode(config.db.pooler.pool_mode, projectEnvValues), ); - const poolerDefaultPoolSize = yield* wrapConfigOverride("db.pooler.default_pool_size", () => + const poolerDefaultPoolSize = yield* wrapConfigOverride( + "db.pooler.default_pool_size", envOverrideDefaultPoolSize(config.db.pooler.default_pool_size, projectEnvValues), ); - const poolerMaxClientConn = yield* wrapConfigOverride("db.pooler.max_client_conn", () => + const poolerMaxClientConn = yield* wrapConfigOverride( + "db.pooler.max_client_conn", envOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), ); @@ -1100,10 +1131,12 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // regardless of `--exclude edge-runtime`. The Edge Runtime branch below re-resolves both // against the env-interpolated subtree for the real container build; this eager call only // needs the raw config value to prove it parses. - const edgeRuntimePolicy = yield* wrapConfigOverride("edge_runtime.policy", () => + const edgeRuntimePolicy = yield* wrapConfigOverride( + "edge_runtime.policy", envOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), ); - const edgeRuntimeInspectorPort = yield* wrapConfigOverride("edge_runtime.inspector_port", () => + const edgeRuntimeInspectorPort = yield* wrapConfigOverride( + "edge_runtime.inspector_port", envOverridePort( "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", config.edge_runtime.inspector_port, @@ -1220,14 +1253,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, dbUrl: values.dbUrl, - env: resolveGotrueEnvInput({ + env: yield* resolveGotrueEnvInput({ context, values, workdir: cliSettings.workdir, kongContainerName, mailpitContainerName, resolvedEmail, - }), + }).pipe(Effect.orDie), }), }; @@ -1332,11 +1365,11 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { kongContainerName, logflareContainerName, studioApiUrl: resolveStudioApiUrl( - envOverride( + (yield* envOverride( "SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues, - ) ?? config.studio.api_url, + )) ?? config.studio.api_url, context.hostname, values.apiUrl, ), @@ -1430,7 +1463,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // `postgresExtraEnv` reads this and its sibling S3 fields for its // `POSTGRES_INITDB_ARGS` branch. orioledb_version: orioledbVersion, - settings: resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + settings: yield* resolveDbSettingsEnvOverrides( + config.db.settings, + projectEnvValues, + ).pipe(Effect.orDie), }, experimental: { ...config.experimental, @@ -1486,7 +1522,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // long-running Realtime/GoTrue/PostgREST containers too), so it's reused, not re-resolved. jwks: Effect.succeed(jwks), apiUrl: values.apiUrl, - authExternalUrl: resolveAuthExternalUrl(context.loaded?.document, projectEnvValues), + authExternalUrl: yield* resolveAuthExternalUrl( + context.loaded?.document, + projectEnvValues, + ), siteUrl: values.authSiteUrl, anonKey: values.anonKey, serviceRoleKey: values.serviceRoleKey, diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index 98d7f7b7ac..f49310fa17 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -1,5 +1,5 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Predicate, Schema } from "effect"; +import { Effect, Predicate, Schema } from "effect"; import { assert, describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; @@ -180,12 +180,14 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => /** Real gates for `config`, with the exclusion factor neutralized (nothing excluded). */ function realGatesFor(config: CliConfig): StartGates { - return resolveStartGates({ - config, - projectEnvValues: undefined, - excludedKeys: new Set(), - document: undefined, - }); + return Effect.runSync( + resolveStartGates({ + config, + projectEnvValues: undefined, + excludedKeys: new Set(), + document: undefined, + }), + ); } function expectGatesMatchMetadata(config: CliConfig, label: string) { diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index 9530f57632..f6267ac51e 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -23,7 +23,7 @@ which branch they linked) can discover which project/branch it's on without a se | `/supabase/config.toml` | TOML | always, to resolve project configuration | | `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | | `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | -| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true`; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true`; the resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) — a path resolving outside it aborts before the read | | `/supabase/.temp/project-ref` | plain text | always (soft) — the linked-state "currently linked ref" lookup (CLI-2167 follow-up, TS-only) | | `/supabase/.temp/linked-project.json` | JSON | always (soft), once linked — determines plain-project-vs-branch state and the display name (CLI-2167 follow-up, TS-only) | diff --git a/apps/cli/src/commands/status/status.handler.ts b/apps/cli/src/commands/status/status.handler.ts index 6eb5f46efe..57d79c79f9 100644 --- a/apps/cli/src/commands/status/status.handler.ts +++ b/apps/cli/src/commands/status/status.handler.ts @@ -141,20 +141,20 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { // 3. Config validation runs entirely before the health check/container listing below, so a // config error (`InvalidJwtSecretError`, a malformed `SUPABASE_*_PORT`/`_ENABLED` override, a // signing-keys-file error) fails here rather than being masked by a Docker/DB error. - const localState = yield* Effect.try({ - try: () => - resolveStatusLocalState( - context.config, - context.hostname, - cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - catch: (cause) => - new StatusInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const localState = yield* resolveStatusLocalState( + context.config, + context.hostname, + cliSettings.workdir, + context.projectEnvValues, + context.loaded?.document, + ).pipe( + Effect.mapError( + (cause) => + new StatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ); // 4. `status` has no --project-id flag: resolution is env → toml → workdir basename, then // sanitized to match the same string the Docker label `start` writes (see @@ -224,7 +224,7 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { // The pretty renderer always uses a fresh, empty override map — `--override-name` only // affects the env/json/toml/yaml path, never the pretty table — remapped from the - // already-resolved `state` so it stays consistent without a second (throwing) resolution. + // already-resolved `state` so it stays consistent without a second (fallible) resolution. const renderPretty = Effect.fnUntraced(function* () { yield* output.raw(`${aqua("supabase")} local development setup is running.\n\n`, "stderr"); const pretty = statusValuesFromState(state, new Map()); diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index bd206c8d98..f541f79fc1 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -21,7 +21,7 @@ model (see the CLI-1324 plan's "Critical architectural finding" for why). | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.json` or `config.toml` | JSON or TOML | backend routing before command parsing when `SUPABASE_EXPERIMENTAL_STACK` is unset or empty; JSON takes precedence when both exist | | `/supabase/config.toml` | TOML | legacy handler default path only; skipped with `--project-id` or `--all` | -| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | default path only, only when `auth.enabled`, for every configured template and every notification with `enabled = true`, as part of `resolveLocalConfigValues`'s own `Config.Validate` pass; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | default path only, only when `auth.enabled`, for every configured template and every notification with `enabled = true`, as part of `resolveLocalConfigValues`'s own `Config.Validate` pass; the resolved path is CONFINED to the project root (symlinks dereferenced using `fs.realPath`) — a path resolving outside it aborts before the read | ## Files Written diff --git a/apps/cli/src/commands/stop/stop.handler.ts b/apps/cli/src/commands/stop/stop.handler.ts index 0fa66dd17c..23525ed499 100644 --- a/apps/cli/src/commands/stop/stop.handler.ts +++ b/apps/cli/src/commands/stop/stop.handler.ts @@ -60,22 +60,22 @@ const resolveSearchProjectIdFilter = Effect.fn("stop.resolveSearchProjectIdFilte // Runs full config validation before touching Docker, unlike the `--all`/`--project-id` // branches above which bypass config loading. `resolveLocalConfigValues` is reused purely for - // its throwing side effects — its resolved URLs/keys are discarded — giving `stop` the same + // its validation failures — its resolved URLs/keys are discarded — giving `stop` the same // config-validation coverage `status` already has. - yield* Effect.try({ - try: () => - resolveLocalConfigValues( - context.config, - context.hostname, - cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - catch: (cause) => - new StopConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + yield* resolveLocalConfigValues( + context.config, + context.hostname, + cliSettings.workdir, + context.projectEnvValues, + context.loaded?.document, + ).pipe( + Effect.mapError( + (cause) => + new StopConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ); return context.projectId; });