From 993caf3cd526147b966df7bc0b1d39141bf6328a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 11:11:59 +0100 Subject: [PATCH 01/81] feat(cli): port supabase stop and status commands to native TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Go-proxy stubs for `stop`/`status` with native Effect implementations that talk directly to Docker/Podman via subprocess, replicating Go's label-filtering and container-naming scheme byte-for-byte. Legacy `start` is still Go-proxied, so this intentionally does not go through `@supabase/stack/effect`'s daemon-based orchestration model — that substrate is incompatible with the real containers Go's binary creates. Adds shared Docker/config infrastructure (`legacy-docker-lifecycle`, `legacy-go-jwt`, `legacy-local-config-values`, `legacy-api-url`) used by both commands, and fixes several correctness issues found during review: stdout colorization keyed off the wrong stream's TTY status, `--override-name` incorrectly leaking into pretty-mode output (Go ignores it there), and the `--backup`/`--no-backup` formula not matching Go's actual (dead-flag) behavior. Fixes CLI-1324 --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/status/SIDE_EFFECTS.md | 167 +++-- .../legacy/commands/status/status.command.ts | 42 +- .../legacy/commands/status/status.errors.ts | 35 + .../legacy/commands/status/status.handler.ts | 219 +++++- .../status/status.integration.test.ts | 480 ++++++++++++++ .../legacy/commands/status/status.pretty.ts | 276 ++++++++ .../status/status.pretty.unit.test.ts | 267 ++++++++ .../legacy/commands/status/status.values.ts | 248 +++++++ .../status/status.values.unit.test.ts | 388 +++++++++++ .../src/legacy/commands/stop/SIDE_EFFECTS.md | 104 ++- .../src/legacy/commands/stop/stop.command.ts | 26 +- .../src/legacy/commands/stop/stop.errors.ts | 50 ++ .../src/legacy/commands/stop/stop.handler.ts | 238 ++++++- .../commands/stop/stop.integration.test.ts | 627 +++++++++++++++++- apps/cli/src/legacy/shared/legacy-api-url.ts | 26 + apps/cli/src/legacy/shared/legacy-colors.ts | 33 +- .../legacy/shared/legacy-colors.unit.test.ts | 50 ++ .../src/legacy/shared/legacy-container-cli.ts | 60 +- .../shared/legacy-container-cli.unit.test.ts | 51 +- .../src/legacy/shared/legacy-docker-ids.ts | 47 ++ .../shared/legacy-docker-ids.unit.test.ts | 45 +- .../legacy/shared/legacy-docker-lifecycle.ts | 240 +++++++ .../legacy-docker-lifecycle.unit.test.ts | 314 +++++++++ apps/cli/src/legacy/shared/legacy-go-jwt.ts | 38 ++ .../legacy/shared/legacy-go-jwt.unit.test.ts | 65 ++ .../shared/legacy-local-config-values.ts | 111 ++++ .../legacy-local-config-values.unit.test.ts | 110 +++ .../shared/legacy-storage-credentials.ts | 19 +- .../src/shared/cli/hidden-flag.unit.test.ts | 10 +- 30 files changed, 4240 insertions(+), 150 deletions(-) create mode 100644 apps/cli/src/legacy/commands/status/status.errors.ts create mode 100644 apps/cli/src/legacy/commands/status/status.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.pretty.ts create mode 100644 apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.values.ts create mode 100644 apps/cli/src/legacy/commands/status/status.values.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.errors.ts create mode 100644 apps/cli/src/legacy/shared/legacy-api-url.ts create mode 100644 apps/cli/src/legacy/shared/legacy-colors.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-jwt.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-local-config-values.ts create mode 100644 apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f5969bd199..7d73ba4d0b 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -269,8 +269,8 @@ Legend: | `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | | `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | | `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | | `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index e27d895e35..4d9579644d 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | ## API Routes @@ -18,65 +18,158 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither this command nor any of its dependencies make a Management API call — everything is +resolved from local `config.toml` and the local Docker daemon. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | +| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | + +`docker` (or `podman` as a fallback) must be on `PATH`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — status displayed | -| `1` | malformed config | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — status displayed | +| `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | +| `1` | `supabase/config.toml` missing or malformed | +| `1` | a malformed `--override-name` entry | +| `1` | listing running containers failed (Docker daemon unreachable, etc.) | +| `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints a table of service names, container IDs, images, and URLs. +Default (`-o` unset or `-o pretty`): a stderr banner, then 5 grouped rounded-border tables on +stdout. Empty rows (a value with nothing resolved) and entirely empty groups are skipped; a +blank line follows every group, rendered or not. ``` - supabase local development setup is running. - - API URL: http://127.0.0.1:54321 - GraphQL URL: http://127.0.0.1:54321/graphql/v1 - S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3 - DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres - Studio URL: http://127.0.0.1:54323 - Inbucket URL: http://127.0.0.1:54324 - JWT secret: super-secret-jwt-token-with-at-least-32-characters-long - anon key: ... -service_role key: ... - S3 Access Key: 625729a08b95bf1b7ff351a663f3a23c - S3 Secret Key: 850181e4652dd023b7a98c58ae0d2d34bd487ee0ead3abe0 - S3 Region: local +supabase local development setup is running. + +╭──────────────────────────────────────╮ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://127.0.0.1:54323 │ +│ Mailpit │ http://127.0.0.1:54324 │ +│ MCP │ http://127.0.0.1:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://127.0.0.1:54321 │ +│ REST │ http://127.0.0.1:54321/rest/v1 │ +│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ +│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │ +│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │ +╰─────────────┴────────────────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────────────────────╮ +│ 📦 Storage (S3) │ +├────────────┬──────────────────────────────────────────────────────────────────┤ +│ URL │ http://127.0.0.1:54321/storage/v1/s3 │ +│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │ +│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │ +│ Region │ local │ +╰────────────┴──────────────────────────────────────────────────────────────────╯ ``` -### `--output-format json` +Group table cells are colored on a TTY (Aqua for links, Yellow for keys, Green for labels, bold +headers); colors are stripped on non-TTY/piped output. + +`Stopped services: [ ...]` is written to stderr (Go slice format, e.g. +`[supabase_storage_test supabase_studio_test]`) whenever one of the 13 expected service +containers isn't in the running set. + +### `-o env` + +`KEY="VALUE"` lines (unquoted for integer-looking values), one per resolved field, sorted by +key — see `legacy-go-output.encoders.ts`'s `encodeEnv`. + +### `-o json` ```json { "API_URL": "http://127.0.0.1:54321", - "DB_URL": "postgresql://...", + "DB_URL": "postgresql://postgres:postgres@127.0.0.1:54322/postgres", "ANON_KEY": "...", "SERVICE_ROLE_KEY": "...", + "PUBLISHABLE_KEY": "...", + "SECRET_KEY": "...", "JWT_SECRET": "...", - "S3_ACCESS_KEY": "...", - "S3_SECRET_KEY": "...", - "S3_REGION": "local" + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "...", + "S3_PROTOCOL_REGION": "local" } ``` -### `--output-format stream-json` +Top-level keys sorted alphabetically, 2-space indent, trailing newline (Go `encoding/json` +parity). Fields whose owning service is disabled or excluded are omitted entirely (not emitted +as `null`/`""`). + +### `-o yaml` / `-o toml` + +Same value set as `-o json`, encoded via `encodeYaml`/`encodeToml`. + +### `--output-format json` / `stream-json` (when `-o` is unset or `pretty`) -Not applicable. +Additive — no Go CLI equivalent. Emits the same resolved value map via +`output.success("", values)` / the NDJSON `result` event. ## Notes -- `--override-name` flag overrides specific variable names in env output. -- `-o env` output format uses KEY=VALUE pairs. -- `-o json` output format uses a JSON object. -- `-o pretty` (default) uses the human-readable table format. +- `-o`/`--output` (`env|pretty|json|toml|yaml`) takes priority over `--output-format` whenever + it is set, matching the Go-parity checklist's dual-output-flag rule. `-o pretty` (or `-o` + unset) falls through to `--output-format`'s text/json/stream-json handling. +- `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` remaps a single field's output KEY; the + value and group layout are unaffected. An unknown key or a malformed (non `KEY=VALUE`) entry + fails with `LegacyStatusOverrideParseError`. This only affects the `env`/`json`/`toml`/`yaml` + (`printStatus`) output path — matching Go, the pretty table (`-o pretty` or unset) always + renders with un-overridden names, since Go's `PrettyPrint` unmarshals a fresh, empty `EnvSet{}` + rather than reusing the CLI-supplied, override-populated `CustomName` (`status.go:236-243`). +- When neither `docker` nor `podman` can be spawned at all, the error message names the actual + root cause (e.g. "docker: command not found (podman also not found) — install Docker Desktop or + Podman and ensure it is on PATH") rather than a generic "failed to ..." string. +- `--exclude ` (hidden) omits a service from the value map by container id only — + Go additionally supports excluding by Docker image short-name, which has no `@supabase/config` + schema equivalent to check against, so that branch is not replicated (documented divergence). +- `--ignore-health-check` (hidden) skips the db container health assertion entirely and always + exits `0`, matching Go's early-return in `Run()`. +- Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a + Go-byte-exact HS256 signer (`legacy-go-jwt.ts`), not `@supabase/stack`'s `generateJwt` — the + latter uses a different issuer, expiry, and claim order that would not match what Go prints + for local dev keys. +- `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; + Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced + identically in `legacy-local-config-values.ts`. +- No e2e test is planned for this command: there is no Docker-daemon-free golden path, and the + e2e harness (`runSupabase()`) does not provision a real local stack. This is a scope reduction + relative to the Linear issue's "E2E compatibility test added" checkbox; see the port plan for + the full justification. diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index f10c14fafe..d08c723235 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -1,5 +1,14 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../shared/legacy-go-output-flag.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStatus } from "./status.handler.ts"; const config = { @@ -8,10 +17,32 @@ const config = { Flag.withDescription("Override specific variable names."), Flag.withDefault([] as ReadonlyArray), ), + exclude: Flag.string("exclude").pipe( + Flag.atLeast(0), + Flag.withDescription("Names of containers to omit from output."), + Flag.withDefault([] as ReadonlyArray), + Flag.withHidden, + ), + ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( + Flag.withDescription("Ignore unhealthy services and exit 0"), + Flag.withHidden, + ), } as const; export type LegacyStatusFlags = CliCommand.Command.Config.Infer; +// `status` makes no Management API calls (Go's status needs no access token), so +// it deliberately avoids `legacyManagementApiRuntimeLayer` — mirrors `unlink`'s +// runtime shape. `legacyCliConfigLayer` is exposed at the top level directly +// (nothing else in this runtime needs to consume it internally). +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStatusRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["status"]), +); + export const legacyStatusCommand = Command.make("status", config).pipe( Command.withDescription("Show status of local Supabase containers."), Command.withShortDescription("Show status of local Supabase containers"), @@ -25,5 +56,14 @@ export const legacyStatusCommand = Command.make("status", config).pipe( description: "Output status as JSON", }, ]), - Command.withHandler((flags) => legacyStatus(flags)), + Command.withHandler((flags) => + legacyStatus(flags).pipe( + withLegacyCommandInstrumentation({ + flags, + outputFormats: LEGACY_RESOURCE_OUTPUT_FORMATS, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyStatusRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts new file mode 100644 index 0000000000..786e7fa8ff --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -0,0 +1,35 @@ +import { Data } from "effect"; + +/** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ +export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ + readonly message: string; +}> {} + +/** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ +export class LegacyStatusOverrideParseError extends Data.TaggedError( + "LegacyStatusOverrideParseError", +)<{ + readonly message: string; +}> {} + +/** Inspecting the db container failed for a reason other than "not found". */ +export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ + readonly message: string; +}> {} + +/** The db container is absent or present but not in the `running` state. */ +export class LegacyStatusDbNotRunningError extends Data.TaggedError( + "LegacyStatusDbNotRunningError", +)<{ + readonly message: string; +}> {} + +/** The db container is running but its Docker health check is not `healthy`. */ +export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ + readonly message: string; +}> {} + +/** Listing running containers by label failed. */ +export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index da50886773..2444a07d54 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,10 +1,217 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Option } from "effect"; + +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyInspectContainerState, + legacyListContainersByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../shared/legacy-go-output.encoders.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { + LegacyStatusConfigLoadError, + LegacyStatusDbInspectError, + LegacyStatusDbNotReadyError, + LegacyStatusDbNotRunningError, + LegacyStatusListError, + LegacyStatusOverrideParseError, +} from "./status.errors.ts"; +import { legacyRenderStatusPretty } from "./status.pretty.ts"; +import { + LEGACY_STATUS_FIELDS, + legacyStatusContainerIds, + legacyStatusValues, +} from "./status.values.ts"; + +/** + * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a + * `fieldKey -> outputName` map, mirroring Go's `env.EnvironToEnvSet` + + * `env.Unmarshal` (`cmd/status.go:21-27`): each entry must be a `KEY=VALUE` + * pair whose `KEY` matches one of the 18 known `CustomName` field keys. + */ +function parseOverrides( + entries: ReadonlyArray, +): Effect.Effect, LegacyStatusOverrideParseError> { + const knownKeys = new Set(LEGACY_STATUS_FIELDS.map((field) => field.fieldKey)); + const overrides = new Map(); + for (const entry of entries) { + const separatorIndex = entry.indexOf("="); + if (separatorIndex <= 0) { + return Effect.fail( + new LegacyStatusOverrideParseError({ + message: `invalid override-name entry, expected KEY=VALUE: ${entry}`, + }), + ); + } + const key = entry.slice(0, separatorIndex); + const value = entry.slice(separatorIndex + 1); + if (!knownKeys.has(key)) { + return Effect.fail( + new LegacyStatusOverrideParseError({ + message: `unknown override-name key: ${key}`, + }), + ); + } + overrides.set(key, value); + } + return Effect.succeed(overrides); +} + +/** Go's `fmt.Fprintln(os.Stderr, "Stopped services:", stopped)` slice format. */ +function formatGoStringSlice(items: ReadonlyArray): string { + return `[${items.join(" ")}]`; +} export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyStatusFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["status"]; - for (const override of flags.overrideName) args.push("--override-name", override); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + yield* Effect.gen(function* () { + // 1. `status` always needs config, unlike `stop` (status.go:99-103). + const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + if (loaded === null) { + return yield* Effect.fail( + new LegacyStatusConfigLoadError({ + message: "failed to read config: supabase/config.toml not found", + }), + ); + } + const config = loaded.config; + + // 2. status has no --project-id flag; resolution is always env → toml → workdir basename. + const projectId = legacyResolveLocalProjectId( + process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ); + const dbContainerId = localDbContainerId(projectId); + + // 3. Health check, skipped entirely with --ignore-health-check (status.go:104-108). + // Go's `assertContainerHealthy` never special-cases "not found" — an absent + // container fails `ContainerInspect` itself, which surfaces as the generic + // inspect error (status.go:147-150), not the "not running" branch (which + // only applies to a present-but-stopped container, status.go:150-151). + if (!flags.ignoreHealthCheck) { + const state = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( + Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), + ); + if (state === "absent") { + return yield* Effect.fail( + new LegacyStatusDbInspectError({ + message: "failed to inspect container health: no such container", + }), + ); + } + if (!state.running) { + return yield* Effect.fail( + new LegacyStatusDbNotRunningError({ + message: `${dbContainerId} container is not running: ${state.status}`, + }), + ); + } + if (state.health !== undefined && state.health !== "healthy") { + return yield* Effect.fail( + new LegacyStatusDbNotReadyError({ + message: `${dbContainerId} container is not ready: ${state.health}`, + }), + ); + } + } + + // 4. List running containers, diff against the 13 expected service ids + // (status.go:125-145), and report any that are stopped. + const filterValue = legacyCliProjectFilterValue(projectId); + const runningNames = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: false, + format: "names", + }).pipe(Effect.mapError((cause) => new LegacyStatusListError({ message: cause.message }))); + const runningSet = new Set(runningNames); + const serviceIds = legacyServiceContainerIds(projectId); + const stopped = serviceIds.filter((id) => !runningSet.has(id)); + if (stopped.length > 0) { + yield* output.raw(`Stopped services: ${formatGoStringSlice(stopped)}\n`, "stderr"); + } + + // 5. Merge health-derived exclusions with the user's --exclude flag. + const excluded = [...stopped, ...flags.exclude]; + + // 6. Build the value map (Go's toValues()). + const containerIds = legacyStatusContainerIds(projectId); + const hostname = legacyGetHostname(); + + // 7. --override-name KEY=VALUE parsing. + const overrides = yield* parseOverrides(flags.overrideName); + + // `names` is intentionally unused here: the pretty-mode branch below + // recomputes with an empty override map (matching Go), and every other + // branch only needs `values`. + const { values } = legacyStatusValues(config, containerIds, hostname, excluded, overrides); + + // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over + // --output-format; -o pretty/unset falls through to text/json/stream-json. + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + yield* output.raw(encodeEnv(values) + "\n"); + return; + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(values)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(values) + "\n"); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(values)); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for json/stream-json, + // otherwise render the grouped rounded-table (Go's `-o pretty` default). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", values); + return; + } + + yield* output.raw( + `${legacyAqua("supabase")} local development setup is running.\n\n`, + "stderr", + ); + // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty + // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the + // CLI-supplied, override-populated `names` — `--override-name` only ever + // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. + // Recompute with an empty override map so the rendered table matches Go + // exactly instead of leaking `--override-name` into pretty-mode output. + const pretty = legacyStatusValues(config, containerIds, hostname, excluded, new Map()); + yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts new file mode 100644 index 0000000000..ac39129679 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -0,0 +1,480 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; +import type { LegacyStatusFlags } from "./status.command.ts"; +import { legacyStatus } from "./status.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); + +function flags(overrides: Partial = {}): LegacyStatusFlags { + return { + overrideName: [], + exclude: [], + ignoreHealthCheck: false, + ...overrides, + }; +} + +function writeConfig(workdir: string, contents = 'project_id = "demo"\n') { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** Same routing-by-argv mock spawner shape as `stop.integration.test.ts`. */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(5000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); +const HEALTHY_DB_STATE = JSON.stringify({ Status: "running", Health: { Status: "healthy" } }); + +/** + * Default happy-path router: db container inspect reports healthy+running, `ps` + * (names format) lists every one of the 13 expected services as running. + */ +function defaultRoute( + opts: { + readonly runningNames?: ReadonlyArray; + readonly dbInspectStdout?: string; + readonly dbInspectExitCode?: number; + readonly dbInspectStderr?: ReadonlyArray; + } = {}, +) { + const runningNames = opts.runningNames ?? ALL_RUNNING_NAMES; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "container" && args[1] === "inspect") { + return { + exitCode: opts.dbInspectExitCode ?? 0, + stdout: [opts.dbInspectStdout ?? HEALTHY_DB_STATE], + stderr: opts.dbInspectStderr, + }; + } + if (args[0] === "ps") return { stdout: runningNames }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly skipConfig?: boolean; + readonly configContents?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configContents); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, + }); + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), + ); + + return { workdir, out, telemetry, child, layer }; +} + +describe("legacy status integration", () => { + it.live("shows the running stack as a pretty table", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🔧 Development Tools"); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.stdoutText).toContain("⛁ Database"); + expect(out.stdoutText).toContain("🔑 Authentication Keys"); + expect(out.stdoutText).toContain("📦 Storage (S3)"); + expect(out.stdoutText).toContain("postgresql://postgres:postgres@"); + expect(out.stderrText).not.toContain("Stopped services:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("skips the db health check with --ignore-health-check", () => { + const { layer, child } = setup({ + route: (args) => { + // db inspect would fail if called; ps still needs to succeed. + if (args[0] === "container" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "ps") return { stdout: ALL_RUNNING_NAMES }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect")).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports stopped services on stderr", () => { + const { layer, out } = setup({ + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const missing = ALL_RUNNING_NAMES[0]; + expect(out.stderrText).toContain(`Stopped services: [${missing}]`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is missing entirely", () => { + const { layer } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when both docker and podman are missing", () => { + // Neither container runtime can be spawned at all — distinct from a spawned + // process exiting non-zero (covered by the malformed/unhealthy scenarios + // above). + const { layer } = setup({ failSpawnFor: () => true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ dockerMissing: true }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the last + // matching record for a given argv is the successful one. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when listing running containers errors", () => { + const { layer } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: [HEALTHY_DB_STATE] }; + } + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is not running", () => { + const { layer } = setup({ + route: defaultRoute({ dbInspectStdout: JSON.stringify({ Status: "exited" }) }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbNotRunningError"); + expect(serialized).toContain(localDbContainerId("demo")); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is absent", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectExitCode: 1, + dbInspectStderr: ["Error: No such container: x"], + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is unhealthy", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ Status: "running", Health: { Status: "starting" } }), + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotReadyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when db inspect errors for a reason other than not-found", () => { + const { layer } = setup({ + route: defaultRoute({ dbInspectExitCode: 1, dbInspectStderr: ["permission denied"] }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs env vars with -o env", () => { + const { layer, out } = setup({ goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.stdoutText).toContain("DB_URL="); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs a json object with -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.DB_URL).toContain("postgresql://postgres:postgres@"); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits excluded services from -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs yaml with -o yaml", () => { + const { layer, out } = setup({ goOutput: Option.some("yaml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs toml with -o toml", () => { + const { layer, out } = setup({ goOutput: Option.some("toml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL ="); + }).pipe(Effect.provide(layer)); + }); + + it.live("remaps an output key with --override-name api.url=NEXT_PUBLIC_SUPABASE_URL", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.API_URL).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on a malformed --override-name entry", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags({ overrideName: ["not-a-kv-pair"] }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on an --override-name entry with an unknown field key", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result with --output-format json when -o is unset", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ API_URL: "http://127.0.0.1:54321" }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o takes priority over --output-format when both are passed", () => { + const { layer, out } = setup({ format: "json", goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // -o env wins: raw KEY="VALUE" text on stdout, not a structured success message. + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + route: (args) => + args[0] === "container" && args[1] === "inspect" ? { exitCode: 1 } : { exitCode: 0 }, + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStatus(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.pretty.ts b/apps/cli/src/legacy/commands/status/status.pretty.ts new file mode 100644 index 0000000000..2999e4ef50 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.ts @@ -0,0 +1,276 @@ +import { legacyAqua, legacyBold, legacyGreen, legacyYellow } from "../../shared/legacy-colors.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +/** + * Port of Go's `PrettyPrint` / `OutputGroup.printTable` + * (`apps/cli-go/internal/status/status.go:236-392`), reproducing + * `tablewriter.NewTable` with `tw.StyleRounded` byte-for-byte for the fixed + * 5-group, 2-column layout `status` needs. This is not a general tablewriter + * port — column sizing, wrapping, and merge behavior are only implemented to the + * extent this command's rounded box needs them. + * + * Column 0 (the label column) is capped at 16 display columns + * (`ColMaxWidths.PerColumn[0] = 16`, `status.go:344`); a label wider than that + * word-wraps across multiple lines, leaving column 1 blank on the continuation + * lines (verified against a real `tablewriter@v1.1.4` render — see the port + * plan). None of the fixed labels below reach 17 characters today, so this is + * defensive parity rather than an observed case. + * + * This does not reuse `legacy/output/legacy-glamour-table.ts` — that helper + * byte-matches Go's `glamour.RenderTable(..., AsciiStyle)`, a single ASCII table + * with a different border style used by other commands. `status`'s Go source + * renders with `tablewriter`/`tw.StyleRounded` into 5 separate grouped, colored, + * Unicode-rounded-box tables, which is a different rendering contract entirely. + * + * Every color call below styles text written to **stdout** (via `output.raw` + * with no stream argument in `status.handler.ts`), so each one explicitly passes + * `process.stdout` to `legacy-colors.ts`'s helpers — they default to + * `process.stderr`, which would check the wrong stream's TTY status here. + */ + +type OutputKind = "text" | "link" | "key"; + +interface OutputItem { + readonly label: string; + readonly value: string; + readonly kind: OutputKind; +} + +interface OutputGroup { + readonly name: string; + readonly items: ReadonlyArray; +} + +const COLUMN_0_MAX_WIDTH = 16; + +/** + * Builds the 5 fixed groups Go's `PrettyPrint` declares (`status.go:245-285`), + * looking up each label's value by output KEY from the resolved value map — + * `--override-name` remaps the KEY but never the group layout, matching Go + * (`values[names.StudioURL]`, not a hardcoded default name). + */ +function buildGroups( + values: Readonly>, + names: LegacyStatusOutputNames, +): ReadonlyArray { + const at = (key: string) => values[key] ?? ""; + return [ + { + name: "🔧 Development Tools", + items: [ + { label: "Studio", value: at(names.studioUrl), kind: "link" }, + { label: "Mailpit", value: at(names.mailpitUrl), kind: "link" }, + { label: "MCP", value: at(names.mcpUrl), kind: "link" }, + ], + }, + { + name: "🌐 APIs", + items: [ + { label: "Project URL", value: at(names.apiUrl), kind: "link" }, + { label: "REST", value: at(names.restUrl), kind: "link" }, + { label: "GraphQL", value: at(names.graphqlUrl), kind: "link" }, + { label: "Edge Functions", value: at(names.functionsUrl), kind: "link" }, + ], + }, + { + name: "⛁ Database", + items: [{ label: "URL", value: at(names.dbUrl), kind: "link" }], + }, + { + name: "🔑 Authentication Keys", + items: [ + { label: "Publishable", value: at(names.publishableKey), kind: "key" }, + { label: "Secret", value: at(names.secretKey), kind: "key" }, + ], + }, + { + name: "📦 Storage (S3)", + items: [ + { label: "URL", value: at(names.storageS3Url), kind: "link" }, + { label: "Access Key", value: at(names.storageS3AccessKeyId), kind: "key" }, + { label: "Secret Key", value: at(names.storageS3SecretAccessKey), kind: "key" }, + { label: "Region", value: at(names.storageS3Region), kind: "text" }, + ], + }, + ]; +} + +/** + * Display width, matching `go-runewidth`'s treatment closely enough for this + * command's inputs: URLs/keys/labels are always plain ASCII, so every rune is + * width 1. The only non-ASCII runes ever rendered are the 5 fixed group-title + * emoji, whose exact rendered widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} + * below rather than computed generically (avoids taking a full Unicode + * East-Asian-Width dependency for a 5-value constant table). + */ +function displayWidth(text: string): number { + return [...text].length; +} + +/** Go-rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ +const HEADER_DISPLAY_WIDTH: Readonly> = { + "🔧 Development Tools": 20, + "🌐 APIs": 7, + "⛁ Database": 10, + "🔑 Authentication Keys": 22, + "📦 Storage (S3)": 15, +}; + +/** + * Exported only for direct unit coverage of the fallback branch (a group title + * outside the 5-entry {@link HEADER_DISPLAY_WIDTH} table) — every call site in + * this file only ever passes one of those 5 fixed titles. + */ +export function legacyStatusHeaderWidth(name: string): number { + return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); +} + +/** + * Greedy word-wrap to `width` columns, mirroring tablewriter's column wrapping. + * Exported only for direct unit coverage of the >16-char defensive-wrap branch + * (see the file-level doc comment) — none of this command's real labels reach + * that width today, so `legacyRenderStatusPretty` never exercises it end to end. + */ +export function legacyWrapStatusLabel(text: string, width: number): ReadonlyArray { + if (displayWidth(text) <= width) return [text]; + const words = text.split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (displayWidth(candidate) <= width) { + current = candidate; + } else { + if (current.length > 0) lines.push(current); + current = word; + } + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : [text]; +} + +/** + * Value coloring, mirroring the `switch row.Type` in `printTable` + * (`status.go:372-377`): `Link` → Aqua, `Key` → Yellow, `Text` → unstyled (the + * switch has no `Text` case, so `value` keeps its raw pre-switch assignment). + */ +function colorValue(kind: OutputKind, value: string): string { + switch (kind) { + case "link": + return legacyAqua(value, process.stdout); + case "key": + return legacyYellow(value, process.stdout); + case "text": + return value; + } +} + +interface ColumnLayout { + readonly col0Padded: number; + readonly col1Padded: number; + readonly targetInner: number; +} + +/** + * Computes the padded column widths and total inner (header) width for a group, + * mirroring tablewriter's column-sizing pass: each column is sized from its + * widest content cell (col 0 capped at 16), then both columns widen evenly + * (col 0 taking the larger half of an odd remainder) if the header text is + * wider than the data-driven layout. Exported only for direct unit coverage of + * the header-widens-the-table branch — none of this command's 5 fixed group + * titles are wider than their data today, so `legacyRenderStatusPretty` never + * exercises it end to end (see the file-level doc comment). + */ +export function legacyStatusColumnLayout( + headerWidthValue: number, + col0Contents: ReadonlyArray, + col1Contents: ReadonlyArray, +): ColumnLayout { + const col0Content = Math.min( + COLUMN_0_MAX_WIDTH, + Math.max(...col0Contents.map((text) => displayWidth(text))), + ); + const col1Content = Math.max(...col1Contents.map((text) => displayWidth(text))); + + let col0Padded = col0Content + 2; + let col1Padded = col1Content + 2; + const dataInner = col0Padded + 1 + col1Padded; + const targetInner = Math.max(dataInner, headerWidthValue + 2); + const extra = targetInner - dataInner; + if (extra > 0) { + col0Padded += Math.ceil(extra / 2); + col1Padded += Math.floor(extra / 2); + } + return { col0Padded, col1Padded, targetInner }; +} + +function renderGroupTable(group: OutputGroup): string | undefined { + const rows = group.items.filter((item) => item.value.length > 0); + if (rows.length === 0) return undefined; + + // Column 0 wraps at 16; column 1 is never capped (Go only sets PerColumn[0]). + // Kept as plain text here — color is applied only after padding, below, so an + // ANSI escape is never counted toward the padded display width. + const wrappedRows = rows.map((row) => ({ + lines: legacyWrapStatusLabel(row.label, COLUMN_0_MAX_WIDTH), + kind: row.kind, + value: row.value, + })); + + const { col0Padded, col1Padded, targetInner } = legacyStatusColumnLayout( + legacyStatusHeaderWidth(group.name), + rows.map((row) => row.label), + rows.map((row) => row.value), + ); + const col0Width = col0Padded - 2; + const col1Width = col1Padded - 2; + + // Pad on the plain text first, then apply color/bold — an active ANSI escape + // must never be counted toward the padded display width. + const pad = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - displayWidth(text))); + // The header uses `headerWidth` (the hardcoded emoji-aware width table) rather + // than `displayWidth`, so its padding lines up with the border math above, + // which sized `targetInner` off the same `headerWidth` call. + const padHeader = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - legacyStatusHeaderWidth(text))); + + const lines: string[] = []; + lines.push(`╭${"─".repeat(col0Padded + 1 + col1Padded)}╮`); + lines.push(`│ ${legacyBold(padHeader(group.name, targetInner - 2), process.stdout)} │`); + lines.push(`├${"─".repeat(col0Padded)}┬${"─".repeat(col1Padded)}┤`); + for (const row of wrappedRows) { + row.lines.forEach((line, index) => { + // Only the first wrapped line carries the value; Go's continuation lines + // (from a >16-char label wrapping) leave column 1 blank. + const labelCell = legacyGreen(pad(line, col0Width), process.stdout); + const paddedValue = pad(index === 0 ? row.value : "", col1Width); + const valueCell = index === 0 ? colorValue(row.kind, paddedValue) : paddedValue; + lines.push(`│ ${labelCell} │ ${valueCell} │`); + }); + } + lines.push(`╰${"─".repeat(col0Padded)}┴${"─".repeat(col1Padded)}╯`); + return lines.join("\n"); +} + +/** + * Port of Go's `PrettyPrint` (`status.go:236-294`): renders the 5 fixed groups + * as rounded-border tables, skipping empty rows and empty groups, with a blank + * line after every group (rendered or not — Go's loop always + * `fmt.Fprintln(w)`s after a nil-error `printTable`, even when nothing rendered). + */ +export function legacyRenderStatusPretty( + values: Readonly>, + names: LegacyStatusOutputNames, +): string { + const groups = buildGroups(values, names); + const lines: string[] = []; + for (const group of groups) { + const table = renderGroupTable(group); + if (table !== undefined) { + lines.push(table); + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts new file mode 100644 index 0000000000..be44ac4c75 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyRenderStatusPretty, + legacyStatusColumnLayout, + legacyStatusHeaderWidth, + legacyWrapStatusLabel, +} from "./status.pretty.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +// The renderer applies Go-parity ANSI styling via `legacy-colors.ts`, which +// no-ops on a real non-TTY stream but the vitest process presents its stderr +// as color-capable. Strip escapes so these assertions target the plain +// structural output — the golden contract per the port plan — not whichever +// TTY heuristic the test runner happens to report. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +// Default (un-overridden) output names, matching `status.values.ts`'s +// `resolveOutputNames` with an empty override map — the KEYs the pretty +// renderer looks values up by. +const NAMES: LegacyStatusOutputNames = { + apiUrl: "API_URL", + restUrl: "REST_URL", + graphqlUrl: "GRAPHQL_URL", + storageS3Url: "STORAGE_S3_URL", + mcpUrl: "MCP_URL", + functionsUrl: "FUNCTIONS_URL", + dbUrl: "DB_URL", + studioUrl: "STUDIO_URL", + mailpitUrl: "MAILPIT_URL", + publishableKey: "PUBLISHABLE_KEY", + secretKey: "SECRET_KEY", + storageS3AccessKeyId: "S3_PROTOCOL_ACCESS_KEY_ID", + storageS3SecretAccessKey: "S3_PROTOCOL_ACCESS_KEY_SECRET", + storageS3Region: "S3_PROTOCOL_REGION", +}; + +const FULL_VALUES: Record = { + API_URL: "http://127.0.0.1:54321", + REST_URL: "http://127.0.0.1:54321/rest/v1", + GRAPHQL_URL: "http://127.0.0.1:54321/graphql/v1", + STORAGE_S3_URL: "http://127.0.0.1:54321/storage/v1/s3", + MCP_URL: "http://127.0.0.1:54321/mcp", + FUNCTIONS_URL: "http://127.0.0.1:54321/functions/v1", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + STUDIO_URL: "http://127.0.0.1:54323", + MAILPIT_URL: "http://127.0.0.1:54324", + PUBLISHABLE_KEY: "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + SECRET_KEY: "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c", + S3_PROTOCOL_ACCESS_KEY_SECRET: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + S3_PROTOCOL_REGION: "local", +}; + +describe("legacyRenderStatusPretty", () => { + // Byte-for-byte parity with a real `tablewriter@v1.1.4` + `tw.StyleRounded` + // render of Go's `PrettyPrint` group layout (verified by running the actual + // vendored Go module against this exact value set — see the port plan). + it("matches the Go rounded-table fixture for a fully running stack", () => { + const out = stripAnsi(legacyRenderStatusPretty(FULL_VALUES, NAMES)); + + const expected = [ + "╭──────────────────────────────────────╮", + "│ 🔧 Development Tools │", + "├─────────┬────────────────────────────┤", + "│ Studio │ http://127.0.0.1:54323 │", + "│ Mailpit │ http://127.0.0.1:54324 │", + "│ MCP │ http://127.0.0.1:54321/mcp │", + "╰─────────┴────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────╮", + "│ 🌐 APIs │", + "├────────────────┬─────────────────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "│ REST │ http://127.0.0.1:54321/rest/v1 │", + "│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │", + "│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │", + "╰────────────────┴─────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────────────╮", + "│ 🔑 Authentication Keys │", + "├─────────────┬────────────────────────────────────────────────┤", + "│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │", + "│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │", + "╰─────────────┴────────────────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────────────────────╮", + "│ 📦 Storage (S3) │", + "├────────────┬──────────────────────────────────────────────────────────────────┤", + "│ URL │ http://127.0.0.1:54321/storage/v1/s3 │", + "│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │", + "│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │", + "│ Region │ local │", + "╰────────────┴──────────────────────────────────────────────────────────────────╯", + "", + ].join("\n"); + + expect(out).toBe(expected); + }); + + // Byte-for-byte parity with a real render of a single-row group (Database), + // confirming the header-vs-single-short-row column sizing. All other groups + // are empty in this fixture, so only the Database box should appear. + it("matches the Go rounded-table fixture for a single-row group", () => { + const out = stripAnsi( + legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), + ); + + const expectedTable = [ + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + ].join("\n"); + + expect(out).toContain(expectedTable); + expect(out).toBe(["", "", expectedTable, "", "", ""].join("\n")); + }); + + // All other groups are empty in this fixture, so only the APIs box appears + // (only Project URL, the rest of the group's rows are excluded/disabled). + it("matches the Go rounded-table fixture for a partial APIs group", () => { + const out = stripAnsi(legacyRenderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); + + const expectedTable = [ + "╭──────────────────────────────────────╮", + "│ 🌐 APIs │", + "├─────────────┬────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "╰─────────────┴────────────────────────╯", + ].join("\n"); + + expect(out).toBe(["", expectedTable, "", "", "", ""].join("\n")); + }); + + it("skips a row whose value is missing from the value map", () => { + // Only Studio present; Mailpit/MCP absent from the map entirely (excluded + // or disabled upstream in `status.values.ts`) — same as an empty string. + const out = stripAnsi( + legacyRenderStatusPretty({ STUDIO_URL: "http://127.0.0.1:54323" }, NAMES), + ); + + expect(out).toContain("Studio"); + expect(out).not.toContain("Mailpit"); + expect(out).not.toContain("MCP"); + }); + + it("skips an entirely empty group but still emits its trailing blank line", () => { + // Nothing present for Development Tools; only the Database URL is set. + const out = stripAnsi(legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, NAMES)); + const lines = out.split("\n"); + + // No rounded-box characters before the Database group's own box. + expect(lines[0]).not.toMatch(/[╭│╰]/); + expect(lines[0]).toBe(""); + expect(out).not.toContain("Development Tools"); + expect(out).toContain("⛁ Database"); + }); + + it("returns only blank lines when every group is empty", () => { + const out = stripAnsi(legacyRenderStatusPretty({}, NAMES)); + // One blank line per group (5 groups), none of them rendering a table. + expect(out).toBe(["", "", "", "", ""].join("\n")); + }); + + // `legacyRenderStatusPretty` is a pure lookup: it renders whatever `values` + // are reachable through `names`' keys, with no opinion on how the caller + // derived either. This is NOT asserting that `--override-name` reaches + // pretty-mode output in production — `status.handler.ts` deliberately always + // calls this function with un-overridden names (matching Go's `PrettyPrint`, + // which unmarshals a fresh empty `EnvSet{}` rather than the CLI's overridden + // `CustomName`). This test only proves the renderer's KEY-based lookup itself + // works correctly for an arbitrary names/values pairing. + it("resolves values through whatever KEY the names parameter specifies", () => { + const overriddenNames: LegacyStatusOutputNames = { + ...NAMES, + apiUrl: "NEXT_PUBLIC_SUPABASE_URL", + }; + const out = stripAnsi( + legacyRenderStatusPretty( + { NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321" }, + overriddenNames, + ), + ); + expect(out).toContain("http://127.0.0.1:54321"); + }); +}); + +// None of `status`'s 18 fixed field labels or 5 fixed group titles are wide +// enough to exercise these two branches through the public +// `legacyRenderStatusPretty` API today (see the file-level doc comment on +// `status.pretty.ts`) — covered directly here as defensive Go-parity logic. +describe("legacyWrapStatusLabel", () => { + it("returns the text unwrapped when it fits within the width", () => { + expect(legacyWrapStatusLabel("Edge Functions", 16)).toEqual(["Edge Functions"]); + }); + + it("word-wraps a label wider than the column width", () => { + expect(legacyWrapStatusLabel("This Is A Very Long Label Name", 16)).toEqual([ + "This Is A Very", + "Long Label Name", + ]); + }); + + it("hard-breaks a single word wider than the column width", () => { + expect(legacyWrapStatusLabel("ThisIsAVeryLongSingleWordLabel", 16)).toEqual([ + "ThisIsAVeryLongSingleWordLabel", + ]); + }); + + it("does not emit a leading empty line when the very first word already overflows", () => { + expect(legacyWrapStatusLabel("SuperLongFirstWord Short", 10)).toEqual([ + "SuperLongFirstWord", + "Short", + ]); + }); + + it("returns the input unchanged for an empty label", () => { + expect(legacyWrapStatusLabel("", 10)).toEqual([""]); + }); + + it("returns the input unchanged for a whitespace-only label wider than the column", () => { + // Every "word" from splitting on spaces is itself empty, so `current` never + // accumulates anything to flush after the loop — the `lines` array stays + // empty and the function falls back to the original text. + expect(legacyWrapStatusLabel(" ", 2)).toEqual([" "]); + }); +}); + +describe("legacyStatusColumnLayout", () => { + it("sizes columns from content alone when the header already fits", () => { + const layout = legacyStatusColumnLayout(10, ["URL"], ["postgresql://short"]); + expect(layout.targetInner).toBe(3 + 2 + 1 + "postgresql://short".length + 2); + }); + + it("widens both columns evenly when the header is wider than the data", () => { + // Base data-driven layout: col0="a"(1+2=3), col1="b"(1+2=3), dataInner=3+1+3=7. + // A 10-char header needs innerWidth=12, so 5 extra columns split 3/2. + const layout = legacyStatusColumnLayout(10, ["a"], ["b"]); + expect(layout.targetInner).toBe(12); + expect(layout.col0Padded).toBe(6); + expect(layout.col1Padded).toBe(5); + }); + + it("caps column 0's content width at 16 even when a label is longer", () => { + const layout = legacyStatusColumnLayout(0, ["a".repeat(30)], ["b"]); + expect(layout.col0Padded).toBe(18); + }); +}); + +describe("legacyStatusHeaderWidth", () => { + it("uses the hardcoded emoji-aware width for a known fixed group title", () => { + expect(legacyStatusHeaderWidth("⛁ Database")).toBe(10); + }); + + it("falls back to code-point length for a title outside the fixed table", () => { + expect(legacyStatusHeaderWidth("Plain Title")).toBe("Plain Title".length); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts new file mode 100644 index 0000000000..6b6fa3d5b9 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -0,0 +1,248 @@ +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; +import { + legacyResolveLocalConfigValues, + type LegacyLocalConfigValues, +} from "../../shared/legacy-local-config-values.ts"; + +/** + * Port of Go's `status.CustomName` + `toValues()` (`internal/status/status.go:29-97`). + * Each field's Go `env:"..."` tag carries two things: the dotted key + * `--override-name =` matches against (`fieldKey` below), and the + * default output env-var name (`defaultName`). `deprecated` fields (`inbucket`, + * `jwt_secret`, `anon_key`, `service_role_key`) are still emitted — Go's + * `deprecated` tag only affects a startup warning it never wires up for `status` + * (only `env.Unmarshal` reads the tag, and it does not warn), so no divergence here. + */ +export interface LegacyStatusField { + readonly fieldKey: string; + readonly defaultName: string; +} + +const API_URL: LegacyStatusField = { fieldKey: "api.url", defaultName: "API_URL" }; +const REST_URL: LegacyStatusField = { fieldKey: "api.rest_url", defaultName: "REST_URL" }; +const GRAPHQL_URL: LegacyStatusField = { fieldKey: "api.graphql_url", defaultName: "GRAPHQL_URL" }; +const STORAGE_S3_URL: LegacyStatusField = { + fieldKey: "api.storage_s3_url", + defaultName: "STORAGE_S3_URL", +}; +const MCP_URL: LegacyStatusField = { fieldKey: "api.mcp_url", defaultName: "MCP_URL" }; +const FUNCTIONS_URL: LegacyStatusField = { + fieldKey: "api.functions_url", + defaultName: "FUNCTIONS_URL", +}; +const DB_URL: LegacyStatusField = { fieldKey: "db.url", defaultName: "DB_URL" }; +const STUDIO_URL: LegacyStatusField = { fieldKey: "studio.url", defaultName: "STUDIO_URL" }; +const INBUCKET_URL: LegacyStatusField = { fieldKey: "inbucket.url", defaultName: "INBUCKET_URL" }; +const MAILPIT_URL: LegacyStatusField = { fieldKey: "mailpit.url", defaultName: "MAILPIT_URL" }; +const PUBLISHABLE_KEY: LegacyStatusField = { + fieldKey: "auth.publishable_key", + defaultName: "PUBLISHABLE_KEY", +}; +const SECRET_KEY: LegacyStatusField = { fieldKey: "auth.secret_key", defaultName: "SECRET_KEY" }; +const JWT_SECRET: LegacyStatusField = { fieldKey: "auth.jwt_secret", defaultName: "JWT_SECRET" }; +const ANON_KEY: LegacyStatusField = { fieldKey: "auth.anon_key", defaultName: "ANON_KEY" }; +const SERVICE_ROLE_KEY: LegacyStatusField = { + fieldKey: "auth.service_role_key", + defaultName: "SERVICE_ROLE_KEY", +}; +const STORAGE_S3_ACCESS_KEY_ID: LegacyStatusField = { + fieldKey: "storage.s3_access_key_id", + defaultName: "S3_PROTOCOL_ACCESS_KEY_ID", +}; +const STORAGE_S3_SECRET_ACCESS_KEY: LegacyStatusField = { + fieldKey: "storage.s3_secret_access_key", + defaultName: "S3_PROTOCOL_ACCESS_KEY_SECRET", +}; +const STORAGE_S3_REGION: LegacyStatusField = { + fieldKey: "storage.s3_region", + defaultName: "S3_PROTOCOL_REGION", +}; + +/** All 18 fields, in `CustomName` struct declaration order. */ +export const LEGACY_STATUS_FIELDS: ReadonlyArray = [ + API_URL, + REST_URL, + GRAPHQL_URL, + STORAGE_S3_URL, + MCP_URL, + FUNCTIONS_URL, + DB_URL, + STUDIO_URL, + INBUCKET_URL, + MAILPIT_URL, + PUBLISHABLE_KEY, + SECRET_KEY, + JWT_SECRET, + ANON_KEY, + SERVICE_ROLE_KEY, + STORAGE_S3_ACCESS_KEY_ID, + STORAGE_S3_SECRET_ACCESS_KEY, + STORAGE_S3_REGION, +]; + +/** The subset of {@link LEGACY_STATUS_FIELDS} the pretty renderer looks up by field. */ +export interface LegacyStatusOutputNames { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly storageS3Url: string; + readonly mcpUrl: string; + readonly functionsUrl: string; + readonly dbUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Resolves each field's output KEY, applying `--override-name =` + * remaps over the Go default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) + * to the replacement output name, mirroring `env.Unmarshal`'s `default=` override. + */ +function resolveOutputNames(overrides: ReadonlyMap): LegacyStatusOutputNames { + const nameFor = (field: LegacyStatusField) => overrides.get(field.fieldKey) ?? field.defaultName; + return { + apiUrl: nameFor(API_URL), + restUrl: nameFor(REST_URL), + graphqlUrl: nameFor(GRAPHQL_URL), + storageS3Url: nameFor(STORAGE_S3_URL), + mcpUrl: nameFor(MCP_URL), + functionsUrl: nameFor(FUNCTIONS_URL), + dbUrl: nameFor(DB_URL), + studioUrl: nameFor(STUDIO_URL), + mailpitUrl: nameFor(MAILPIT_URL), + publishableKey: nameFor(PUBLISHABLE_KEY), + secretKey: nameFor(SECRET_KEY), + storageS3AccessKeyId: nameFor(STORAGE_S3_ACCESS_KEY_ID), + storageS3SecretAccessKey: nameFor(STORAGE_S3_SECRET_ACCESS_KEY), + storageS3Region: nameFor(STORAGE_S3_REGION), + }; +} + +/** + * Container ids `toValues()` gates each group on, taken from + * `legacyServiceContainerIds`'s alias order (`kong`, `auth`, `inbucket`, ..., + * `edge_runtime`, ...) — see `legacy-docker-ids.ts`. + */ +export interface LegacyStatusContainerIds { + readonly kong: string; + readonly auth: string; + readonly inbucket: string; + readonly rest: string; + readonly storage: string; + readonly studio: string; + readonly edgeRuntime: string; +} + +// Positional indices into `legacyServiceContainerIds`'s fixed 13-element +// array (`legacy-docker-ids.ts`'s `GetDockerIds()` order), named so a caller +// never has to destructure the array positionally. +const CONTAINER_INDEX = { + kong: 0, + auth: 1, + inbucket: 2, + rest: 4, + storage: 5, + studio: 8, + edgeRuntime: 9, +} as const; + +/** + * Derives {@link LegacyStatusContainerIds} from `legacyServiceContainerIds`'s + * flat array for a given project id. The array's length and order are a fixed + * Go-parity contract (13 elements, `GetDockerIds()` order), so every named + * index here is guaranteed present — this only exists to give the handler a + * named-field view instead of positional array destructuring. + */ +export function legacyStatusContainerIds(projectId: string): LegacyStatusContainerIds { + const ids = legacyServiceContainerIds(projectId); + const at = (index: number) => ids[index] ?? ""; + return { + kong: at(CONTAINER_INDEX.kong), + auth: at(CONTAINER_INDEX.auth), + inbucket: at(CONTAINER_INDEX.inbucket), + rest: at(CONTAINER_INDEX.rest), + storage: at(CONTAINER_INDEX.storage), + studio: at(CONTAINER_INDEX.studio), + edgeRuntime: at(CONTAINER_INDEX.edgeRuntime), + }; +} + +export interface LegacyStatusValuesResult { + readonly values: Record; + readonly names: LegacyStatusOutputNames; + readonly local: LegacyLocalConfigValues; +} + +/** + * Port of Go's `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`). + * `excluded` matches by container id only — Go's `ShortContainerImageName` branch + * has no schema equivalent to check against (decision #3 in the port plan). + */ +export function legacyStatusValues( + config: ProjectConfig, + containerIds: LegacyStatusContainerIds, + hostname: string, + excluded: ReadonlyArray, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const local = legacyResolveLocalConfigValues(config, hostname); + const names = resolveOutputNames(overrides); + const isExcluded = (id: string) => excluded.includes(id); + + const kongEnabled = config.api.enabled && !isExcluded(containerIds.kong); + const postgrestEnabled = kongEnabled && !isExcluded(containerIds.rest); + const studioEnabled = config.studio.enabled && !isExcluded(containerIds.studio); + const authEnabled = config.auth.enabled && !isExcluded(containerIds.auth); + const inbucketEnabled = config.local_smtp.enabled && !isExcluded(containerIds.inbucket); + const storageEnabled = config.storage.enabled && !isExcluded(containerIds.storage); + const functionsEnabled = config.edge_runtime.enabled && !isExcluded(containerIds.edgeRuntime); + + // Go always sets db.url unconditionally, before any gating (status.go:52). + const values: Record = { + [names.dbUrl]: local.dbUrl, + }; + + if (kongEnabled) { + values[names.apiUrl] = local.apiUrl; + if (postgrestEnabled) { + values[names.restUrl] = local.restUrl; + values[names.graphqlUrl] = local.graphqlUrl; + } + if (functionsEnabled) { + values[names.functionsUrl] = local.functionsUrl; + } + if (studioEnabled) { + values[names.mcpUrl] = local.mcpUrl; + } + } + if (studioEnabled) { + values[names.studioUrl] = local.studioUrl; + } + if (authEnabled) { + values[names.publishableKey] = local.publishableKey; + values[names.secretKey] = local.secretKey; + values[overrides.get(JWT_SECRET.fieldKey) ?? JWT_SECRET.defaultName] = local.jwtSecret; + values[overrides.get(ANON_KEY.fieldKey) ?? ANON_KEY.defaultName] = local.anonKey; + values[overrides.get(SERVICE_ROLE_KEY.fieldKey) ?? SERVICE_ROLE_KEY.defaultName] = + local.serviceRoleKey; + } + if (inbucketEnabled) { + values[names.mailpitUrl] = local.mailpitUrl; + values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; + } + if (storageEnabled && config.storage.s3_protocol.enabled) { + values[names.storageS3Url] = local.storageS3Url; + values[names.storageS3AccessKeyId] = local.storageS3AccessKeyId; + values[names.storageS3SecretAccessKey] = local.storageS3SecretAccessKey; + values[names.storageS3Region] = local.storageS3Region; + } + + return { values, names, local }; +} diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts new file mode 100644 index 0000000000..152197e119 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -0,0 +1,388 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + legacyStatusContainerIds, + legacyStatusValues, + type LegacyStatusContainerIds, +} from "./status.values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +const CONTAINER_IDS: LegacyStatusContainerIds = { + kong: "supabase_kong_test", + auth: "supabase_auth_test", + inbucket: "supabase_inbucket_test", + rest: "supabase_rest_test", + storage: "supabase_storage_test", + studio: "supabase_studio_test", + edgeRuntime: "supabase_edge_runtime_test", +}; + +const HOSTNAME = "127.0.0.1"; +const NONE: ReadonlyArray = []; +const NO_OVERRIDES = new Map(); + +describe("legacyStatusValues", () => { + it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { + const config = baseConfig({ + api: { enabled: false }, + studio: { enabled: false }, + auth: { enabled: false }, + local_smtp: { enabled: false }, + storage: { enabled: false }, + edge_runtime: { enabled: false }, + }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(Object.keys(values)).toEqual(["DB_URL"]); + expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); + }); + + describe("api / kong gating", () => { + it("includes API_URL when api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.API_URL).toBeDefined(); + }); + + it("omits API_URL when api.enabled is false", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits API_URL when the kong container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when only the rest container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.rest], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("includes REST/GraphQL when kong and postgrest are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.REST_URL).toBeDefined(); + expect(values.GRAPHQL_URL).toBeDefined(); + }); + }); + + describe("functions gating", () => { + it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.edgeRuntime], + NO_OVERRIDES, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + }); + + describe("studio / mcp gating", () => { + it("includes STUDIO_URL when studio.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("omits STUDIO_URL when studio.enabled is false", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("omits STUDIO_URL when the studio container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.studio], + NO_OVERRIDES, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("includes MCP_URL only when both kong and studio are enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.MCP_URL).toBeDefined(); + }); + + it("omits MCP_URL when kong is disabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.MCP_URL).toBeUndefined(); + }); + + it("omits MCP_URL when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.MCP_URL).toBeUndefined(); + }); + }); + + describe("auth gating", () => { + it("includes all 5 auth fields when auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.SECRET_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits all 5 auth fields when auth.enabled is false", () => { + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + expect(values.SECRET_KEY).toBeUndefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("omits all 5 auth fields when the auth container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.auth], + NO_OVERRIDES, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + }); + + describe("inbucket/mailpit gating", () => { + it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + 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 } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.inbucket], + NO_OVERRIDES, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); + }); + + describe("storage / s3 gating", () => { + it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_SECRET).toBeDefined(); + expect(values.S3_PROTOCOL_REGION).toBeDefined(); + }); + + it("omits storage S3 fields when storage.enabled is false", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when the storage container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.storage], + NO_OVERRIDES, + ); + 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 } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); + }); + }); + + 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 } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.API_URL).toBeUndefined(); + expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + }); + + it("remaps every field independently when multiple overrides are given", () => { + const overrides = new Map([ + ["api.url", "CUSTOM_API_URL"], + ["db.url", "CUSTOM_DB_URL"], + ]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.CUSTOM_API_URL).toBeDefined(); + expect(values.CUSTOM_DB_URL).toBeDefined(); + expect(values.API_URL).toBeUndefined(); + expect(values.DB_URL).toBeUndefined(); + }); + + it("leaves unrelated fields at their default name when only one is overridden", () => { + const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.REST_URL).toBeDefined(); + }); + + it("remaps the deprecated auth.jwt_secret/anon_key/service_role_key keys", () => { + const overrides = new Map([ + ["auth.jwt_secret", "CUSTOM_JWT_SECRET"], + ["auth.anon_key", "CUSTOM_ANON_KEY"], + ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], + ]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.CUSTOM_JWT_SECRET).toBeDefined(); + expect(values.CUSTOM_ANON_KEY).toBeDefined(); + expect(values.CUSTOM_SERVICE_ROLE_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { + const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + }); + + it("combines stopped-service exclusions with --exclude flag exclusions", () => { + // Both `stopped` (from the health-check diff) and `--exclude` (user flag) + // funnel into the same `excluded` array in the handler; the pure function + // only sees the merged list. + const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + excluded, + NO_OVERRIDES, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.STUDIO_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); + }); +}); + +describe("legacyStatusContainerIds", () => { + it("derives every named field from legacyServiceContainerIds's fixed array order", () => { + const ids = legacyStatusContainerIds("demo"); + expect(ids).toEqual({ + kong: "supabase_kong_demo", + auth: "supabase_auth_demo", + inbucket: "supabase_inbucket_demo", + rest: "supabase_rest_demo", + storage: "supabase_storage_demo", + studio: "supabase_studio_demo", + edgeRuntime: "supabase_edge_runtime_demo", + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 09cf725258..4c918076e5 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -1,16 +1,21 @@ # `supabase stop` +Native TypeScript port of Go's `internal/stop`. Talks directly to Docker via subprocess +(`docker`/`podman`), replicating Go's label-filtering and container-naming scheme +byte-for-byte — it does not go through `@supabase/stack/effect`'s orchestration model +(see the CLI-1324 plan's "Critical architectural finding" for why). + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| -------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -18,37 +23,94 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither `stop` nor its Go counterpart make any Management API call. Everything is local +Docker + local `config.toml`. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id on the default path (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | resolves `LegacyCliConfig.workdir`, which locates `config.toml` on the default path | no (falls back to walking up from cwd for `supabase/config.toml`) | + +`docker`/`podman` must be resolvable on `PATH` (or reachable via the configured Docker +context) — `spawnContainerCli` tries `docker` first and falls back to `podman`. When +neither can be spawned at all, the error message names the actual root cause (e.g. +"docker: command not found (podman also not found) — install Docker Desktop or Podman +and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — all containers stopped | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`LegacyStopMutuallyExclusiveError`) | +| `1` | `config.toml` present but malformed (`LegacyStopConfigLoadError`) — an **absent** file is not an error | +| `1` | listing containers failed (`LegacyStopListError`) | +| `1` | stopping one or more containers failed (`LegacyStopContainerError`) | +| `1` | `docker container prune` failed (`LegacyStopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`LegacyStopVolumePruneError`) | +| `1` | `docker network prune` failed (`LegacyStopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Matches `apps/cli-go/internal/stop/`. Go does not fire any custom telemetry event for +this command. ## Output +Go's `stop` has **no** `-o`/`--output` flag at all, so the Go-compat `LegacyOutputFlag` +is not consulted by this handler — only the TS-native `--output-format` matters here. +This is a harmless, documented divergence: Go would reject an unknown `-o` flag outright. + ### `--output-format text` (Go CLI compatible) -Prints "Stopped supabase local development setup." on success. +- stderr (transient): `Stopping containers...` +- stdout: `Stopped supabase local development setup.` (`supabase` rendered in Aqua/cyan + when the output stream is a TTY, plain otherwise) +- stderr (conditional): when any Docker volume still carries the project's + `com.supabase.cli.project` label after stopping, an additional suggestion line: + - with a project id filter: `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project=` + - with `--all` (empty filter): `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project` ### `--output-format json` -Not applicable — stop is a local-dev workflow command. +Additive — no Go CLI equivalent. Single JSON object via `Output.success`: + +```json +{ "project_id_filter": "demo", "backup": true } +``` ### `--output-format stream-json` -Not applicable — stop is a local-dev workflow command. +Same payload as `json`, delivered as a `result` NDJSON event. ## Notes -- `--no-backup` deletes all data volumes after stopping. -- `--project-id` targets a specific local project ID to stop. -- `--all` stops all local Supabase instances across all projects on the machine. -- `--project-id` and `--all` are mutually exclusive. -- The hidden `--backup` flag (default true) is the inverse of `--no-backup`. +- `--project-id` and `--all` are **directory-independent** pure Docker-label filters — + neither reads `config.toml`. Only the no-flags default path resolves the project id + from `LegacyCliConfig.workdir` (env → config.toml `project_id` → workdir basename). +- The hidden `--backup` flag exists only for Go CLI surface parity — it has **no effect**. + Go declares it via `flags.Bool("backup", true, ...)` (`cmd/stop.go:26`) but never binds + the return value to a variable, so `RunE` always passes `!noBackup` to `stop.Run` + regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = +flags.noBackup`. `--backup=false` alone does **not** delete volumes; only + `--no-backup` does. +- Volume prune always passes `--all`. Go gates that flag on Docker engine >= 1.42 + (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips the + version check and always passes `--all` because every currently supported Docker + version is far past 1.42. +- Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), + mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before + failing the command (rather than stopping at the first failure), matching Go's + `errors.Join` over the full result set — though the surfaced message is a single fixed + string rather than a joined list of per-container errors, since Docker CLI subprocess + stderr isn't captured per-container the way Go's SDK error is. +- No e2e test is planned: there is no Docker-daemon-free golden path for this command, + and the e2e harness (`runSupabase()`) does not provision a real local stack. See the + CLI-1324 plan's "E2e tests" section for the full justification. diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index c89e0c3d1d..b69683c700 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -1,5 +1,13 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStop } from "./stop.handler.ts"; const config = { @@ -24,8 +32,24 @@ const config = { export type LegacyStopFlags = CliCommand.Command.Config.Infer; +// `stop` makes no Management API calls (Go's stop needs no access token) and talks +// directly to Docker, so it deliberately avoids `legacyManagementApiRuntimeLayer` — +// it provides only the services the handler + instrumentation consume. +// `ChildProcessSpawner` is not listed here: it comes from `BunServices` in the root +// runtime (`shared/cli/run.ts`), the same way `gen types`/`unlink` rely on it. +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStopRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["stop"]), +); + export const legacyStopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), - Command.withHandler((flags) => legacyStop(flags)), + Command.withHandler((flags) => + legacyStop(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyStopRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts new file mode 100644 index 0000000000..273a01c67e --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -0,0 +1,50 @@ +import { Data } from "effect"; + +/** + * `--project-id` and `--all` were both set. Best-effort match of cobra's + * `MarkFlagsMutuallyExclusive` message shape (`stopCmd.MarkFlagsMutuallyExclusive("project-id", + * "all")`, `apps/cli-go/cmd/stop.go`). Cobra isn't vendored in this repo, so the exact + * wording could not be verified against source; this mirrors the same phrasing already + * used for `gen types`'s mutually-exclusive flag groups (`types.handler.ts`). + */ +export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( + "LegacyStopMutuallyExclusiveError", +)<{ + readonly message: string; +}> {} + +/** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ +export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ + readonly message: string; +}> {} + +/** + * Listing containers to stop failed. `stop`-specific wrapper over + * `LegacyDockerLifecycleListError` (see `legacy-docker-lifecycle.ts`) so this command's + * errors are all in one file with a `LegacyStop*` tag, matching the plan's error list. + */ +export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ + readonly message: string; +}> {} + +/** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ +export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ + readonly message: string; +}> {} + +/** `docker container prune` failed. */ +export class LegacyStopContainerPruneError extends Data.TaggedError( + "LegacyStopContainerPruneError", +)<{ + readonly message: string; +}> {} + +/** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ +export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ + readonly message: string; +}> {} + +/** `docker network prune` failed. */ +export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ab7f5f9ad8..a2e6863f0e 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,15 +1,231 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Option, Result } from "effect"; + +import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, +} from "../../shared/legacy-container-cli.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; +import { + LegacyStopConfigLoadError, + LegacyStopContainerError, + LegacyStopContainerPruneError, + LegacyStopListError, + LegacyStopMutuallyExclusiveError, + LegacyStopNetworkPruneError, + LegacyStopVolumePruneError, +} from "./stop.errors.ts"; + +/** + * Resolve the Docker label filter `stop` searches on. Go's flag precedence + * (`stop.go:14-22`): `--all` bypasses config entirely with an empty filter; + * `--project-id` overrides `Config.ProjectId` directly, also bypassing + * config.toml; otherwise `flags.LoadConfig` reads config.toml and + * `Config.ProjectId` (env → toml → workdir basename) is used. + */ +const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( + function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { + if (flags.all) return ""; + if (Option.isSome(flags.projectId)) return flags.projectId.value; + + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still + // resolves a project id via the workdir basename default. Only a + // malformed file (`loadProjectConfig` failing rather than returning + // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. + const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + return legacyResolveLocalProjectId( + process.env["SUPABASE_PROJECT_ID"], + loaded?.config.project_id, + cliConfig.workdir, + ); + }, +); export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["stop"]; - if (Option.isSome(flags.projectId)) args.push("--project-id", flags.projectId.value); - // `--backup` defaults to true; only forward when explicitly disabled, which - // matches the Go CLI semantics (`!noBackup` && `--backup=false`). - if (!flags.backup) args.push("--backup=false"); - if (flags.noBackup) args.push("--no-backup"); - if (flags.all) args.push("--all"); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + yield* Effect.gen(function* () { + if (Option.isSome(flags.projectId) && flags.all) { + return yield* Effect.fail( + new LegacyStopMutuallyExclusiveError({ + // Cobra's `validateExclusiveFlagGroups` (spf13/cobra flag_groups.go): + // the group name keeps declaration order (`strings.Join(flagNames, " ")`), + // but the "were all set" list is `sort.Strings`-ed — verified against + // the vendored cobra@v1.10.2 source, not guessed. + message: + "if any flags in the group [project-id all] are set none of the others can be; [all project-id] were all set", + }), + ); + } + + const searchProjectIdFilter = yield* resolveSearchProjectIdFilter(flags, cliConfig); + // Go's hidden `--backup` flag is declared via `flags.Bool("backup", true, ...)` + // (`cmd/stop.go:26`) but its return value is discarded — never bound to a + // variable, so `RunE` always passes `!noBackup` to `stop.Run` regardless of + // `--backup`'s value. `--backup=false` is a no-op in the real Go binary + // today; only `--no-backup` deletes volumes. Matching that exactly (not the + // seemingly-intended-but-dead semantics of the flag's own description). + const deleteVolumes = flags.noBackup; + const filterValue = legacyCliProjectFilterValue(searchProjectIdFilter); + + // Captured (not discarded) so it can be `.fail()`ed or `.clear()`ed below, + // matching the project's established `output.task` usage pattern + // (apps/cli/CLAUDE.md's "always wrap API calls in output.task"). In + // non-interactive/CI runs the spinner never renders, but `.fail()`/`.clear()` + // still resolve cleanly — a discarded handle would otherwise leave a spinner + // that's started but never stopped. A single `Effect.tapError` around the + // whole list/stop/prune sequence (rather than one per step) fails the same + // task on any error without repeating the same branch at every call site. + const stopping = + output.format === "text" ? yield* output.task("Stopping containers...") : undefined; + + yield* Effect.gen(function* () { + const containerIds = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: true, + format: "id", + }).pipe(Effect.mapError((cause) => new LegacyStopListError({ message: cause.message }))); + + // Go stops containers concurrently via `WaitAll`, joining every failure + // rather than short-circuiting on the first one (`docker.go:96-146`). + const stopResults = yield* Effect.all( + containerIds.map((id) => containerCliExitCode(spawner, ["stop", id]).pipe(Effect.result)), + { concurrency: "unbounded" }, + ); + const failedStop = stopResults.find( + (result) => Result.isFailure(result) || result.success !== 0, + ); + if (failedStop !== undefined) { + return yield* Effect.fail( + new LegacyStopContainerError({ + message: `failed to stop container: ${ + Result.isFailure(failedStop) + ? legacyDescribeContainerCliFailure(failedStop.failure) + : `exit ${failedStop.success}` + }`, + }), + ); + } + + const containerPruneExitCode = yield* containerCliExitCode(spawner, [ + "container", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new LegacyStopContainerPruneError({ + message: `failed to prune containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (containerPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopContainerPruneError({ message: "failed to prune containers" }), + ); + } + + if (deleteVolumes) { + // Go gates the `--all` filter arg on Docker engine >= 1.42 (`docker.go:120-124`). + // All currently supported Docker versions are well past 1.42, so the TS port + // always passes `--all` — documented divergence, see SIDE_EFFECTS.md Notes. + const volumePruneExitCode = yield* containerCliExitCode(spawner, [ + "volume", + "prune", + "--force", + "--all", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new LegacyStopVolumePruneError({ + message: `failed to prune volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (volumePruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopVolumePruneError({ message: "failed to prune volumes" }), + ); + } + } + + const networkPruneExitCode = yield* containerCliExitCode(spawner, [ + "network", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new LegacyStopNetworkPruneError({ + message: `failed to prune networks: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (networkPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopNetworkPruneError({ message: "failed to prune networks" }), + ); + } + }).pipe(Effect.tapError(() => stopping?.fail() ?? Effect.void)); + + yield* stopping?.clear() ?? Effect.void; + + if (output.format === "text") { + // Written to stdout (no stream arg): `legacyAqua` must target stdout's own + // TTY status, not stderr's — see `legacy-colors.ts`'s doc comment. + yield* output.raw( + `Stopped ${legacyAqua("supabase", process.stdout)} local development setup.\n`, + ); + } else { + yield* output.success("Stopped supabase local development setup.", { + project_id_filter: searchProjectIdFilter, + backup: !deleteVolumes, + }); + } + + // Post-run suggestion (stop.go:26-37): only meaningful in text mode — json/ + // stream-json payloads have no equivalent field to carry this hint. + if (output.format === "text") { + const remainingVolumes = yield* legacyListVolumesByLabel(spawner, filterValue).pipe( + Effect.orElseSucceed(() => []), + ); + if (remainingVolumes.length > 0) { + const listVolumeCommand = + searchProjectIdFilter.length > 0 + ? `docker volume ls --filter label=com.supabase.cli.project=${searchProjectIdFilter}` + : "docker volume ls --filter label=com.supabase.cli.project"; + yield* output.raw( + `Local data are backed up to docker volume. Use docker to show them: ${legacyAqua(listVolumeCommand)}\n`, + "stderr", + ); + } + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index c9c9d83fea..3f56eead39 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -1,55 +1,614 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyStop } from "./stop.handler.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; -function setupLegacyStop() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); +const tempRoot = useLegacyTempWorkdir("supabase-stop-int-"); + +function flags(overrides: Partial = {}): LegacyStopFlags { + return { + projectId: Option.none(), + backup: true, + noBackup: false, + all: false, + ...overrides, + }; +} + +function writeConfig(workdir: string, projectId: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** + * Routes each spawned invocation to a caller-supplied result by matching argv + * (rather than a fixed call sequence): `stop` issues five distinct docker + * subcommands (`ps`, `stop`, `container prune`, `volume prune`, `network prune`, + * `volume ls`) whose relative order/count varies per scenario (N `stop` calls for + * N listed containers), so a routing table is a better fit than the sequential + * step-array mock `gen types` uses for its single linear pipeline. + */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + // Fails BOTH docker and podman spawn attempts for argv matching this predicate, + // simulating a runtime that cannot be spawned at all (as opposed to a spawned + // process exiting non-zero) — exercises the `Effect.mapError`/`orElseSucceed` + // spawn-failure branches distinct from the exit-code-checking branches. + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +/** Default happy-path router: `ps` lists one container, everything else succeeds empty. */ +function defaultRoute( + opts: { + readonly containerIds?: ReadonlyArray; + readonly volumeNames?: ReadonlyArray; + } = {}, +) { + const containerIds = opts.containerIds ?? ["c1"]; + const volumeNames = opts.volumeNames ?? []; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "ps") return { stdout: containerIds }; + if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly configuredProjectId?: string; + readonly skipConfig?: boolean; +} + +function setup(opts: SetupOpts = {}) { + const workdir = tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configuredProjectId ?? "demo"); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, }); - return { layer, calls }; + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + ); + + return { workdir, out, telemetry, child, layer }; } -const baseFlags: LegacyStopFlags = { - projectId: Option.none(), - backup: true, - noBackup: false, - all: false, -}; +describe("legacy stop integration", () => { + it.live( + "stops the current project's containers with backup and suggests the volume command", + () => { + const { layer, out, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ containerIds: ["c1", "c2"], volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + const stopCalls = child.spawned.filter((s) => s.args[0] === "stop"); + expect(stopCalls.map((s) => s.args)).toEqual([ + ["stop", "c1"], + ["stop", "c2"], + ]); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stdoutText).toContain("local development setup."); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain( + "docker volume ls --filter label=com.supabase.cli.project=demo", + ); + }).pipe(Effect.provide(layer)); + }, + ); -describe("legacy stop", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStop(); + it.live("stops every project's containers with --all without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop(baseFlags); - expect(calls).toEqual([["stop"]]); + yield* legacyStop(flags({ all: true })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.ID}}", + ]); + const pruneCalls = child.spawned.filter( + (s) => s.args[0] === "container" && s.args[1] === "prune", + ); + expect(pruneCalls[0]?.args).toEqual([ + "container", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project", + ]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --backup=false when the hidden --backup flag is disabled", () => { - const { layer, calls } = setupLegacyStop(); + it.live("suggests the bare-label volume command with --all when volumes remain", () => { + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); return Effect.gen(function* () { - yield* legacyStop({ ...baseFlags, backup: false }); - expect(calls).toEqual([["stop", "--backup=false"]]); + yield* legacyStop(flags({ all: true })); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain("docker volume ls --filter label=com.supabase.cli.project"); + expect(out.stderrText).not.toContain("com.supabase.cli.project="); }).pipe(Effect.provide(layer)); }); - it.live("forwards --no-backup, --project-id and --all", () => { - const { layer, calls } = setupLegacyStop(); + it.live("stops a named project with --project-id without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop({ - projectId: Option.some("abc"), - backup: true, - noBackup: true, - all: true, - }); - expect(calls).toEqual([["stop", "--project-id", "abc", "--no-backup", "--all"]]); + yield* legacyStop(flags({ projectId: Option.some("other-project") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=other-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-id together with --all", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: true })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("deletes data volumes with --no-backup", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => { + // Go's `--backup` is declared but never bound to a variable (`cmd/stop.go:26`) — + // `RunE` always passes `!noBackup`, so `--backup=false` has zero effect in the + // real Go binary today. Only `--no-backup` deletes volumes. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: false })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--no-backup still deletes data volumes even when --backup stays true", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: true, noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps data volumes by default (no volume prune call)", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when stopping a container errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when a container cannot be spawned to stop it at all", () => { + // Distinct from a spawned `docker stop` exiting non-zero (covered above) — + // this exercises the branch where docker AND podman both fail to spawn for + // the `stop ` argv specifically. + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { stdout: ["c1"] } : { exitCode: 0 }), + failSpawnFor: (args) => args[0] === "stop", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails cleanly in json mode without a text-mode spinner to dismiss", () => { + // No `output.task` handle exists outside text mode — this exercises that + // the failure path's `stopping?.fail() ?? Effect.void` no-ops correctly. + const { layer } = setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when container prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "container" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "volume" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "network" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the container list errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the + // successful call is the LAST matching record, not the first. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result in json mode without spinner text", () => { + const { layer, out } = setup({ + format: "json", + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_id_filter: "demo", backup: true }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + // json mode has no volume-suggestion equivalent — only text mode emits it. + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("shows no volume suggestion when no volumes remain", () => { + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: [] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { exitCode: 1 } : { exitCode: 0 }), + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStop(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when container prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "container" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "network" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("still reports success when the post-run volume listing fails", () => { + // The volume-suggestion check is best-effort (`Effect.orElseSucceed`): a + // failure listing volumes after a successful stop must not fail the command, + // matching Go's `if resp, err := ...; err == nil && ...` (stop.go:29) — a + // listing error there is silently ignored, not surfaced. + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "ls", + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stderrText).not.toContain("Local data are backed up"); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-api-url.ts b/apps/cli/src/legacy/shared/legacy-api-url.ts new file mode 100644 index 0000000000..c48b73ac44 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-api-url.ts @@ -0,0 +1,26 @@ +/** + * Local API URL derivation, mirroring Go's `config.go:634-644` + `misc.go:298`: + * an explicit `api.external_url` wins, otherwise `://:` + * where the scheme follows `api.tls.enabled` and the port is `api.port`. + * Hoisted here because `legacy-storage-credentials.ts` and + * `legacy-local-config-values.ts` both need this exact computation. + */ +export function legacyResolveApiExternalUrl( + config: { + readonly external_url?: string; + readonly port: number; + readonly tls: { readonly enabled: boolean }; + }, + hostname: string, +): string { + if (config.external_url !== undefined && config.external_url.length > 0) { + return config.external_url; + } + const scheme = config.tls.enabled ? "https" : "http"; + // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an + // IPv6 host. + const hostPort = hostname.includes(":") + ? `[${hostname}]:${config.port}` + : `${hostname}:${config.port}`; + return `${scheme}://${hostPort}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-colors.ts b/apps/cli/src/legacy/shared/legacy-colors.ts index c41cfae7d4..25d32d7c42 100644 --- a/apps/cli/src/legacy/shared/legacy-colors.ts +++ b/apps/cli/src/legacy/shared/legacy-colors.ts @@ -6,27 +6,38 @@ import { styleText } from "node:util"; * Go uses lipgloss, which auto-detects the output profile and renders **plain** * text when the stream is not a TTY (piped output, CI, tests). `styleText` * mirrors that: with `validateStream` (the default) it checks the target stream - * and `NO_COLOR`, returning the unstyled string when colour is unsupported. We - * point it at `process.stderr` because the bootstrap progress / suggestion lines - * these style are written to stderr. + * and `NO_COLOR`, returning the unstyled string when colour is unsupported. + * + * `stream` defaults to `process.stderr` because every original call site styles + * progress/suggestion lines written to stderr. A caller styling content that is + * itself written to **stdout** (e.g. `status`'s pretty table) must pass + * `process.stdout` explicitly — otherwise the TTY check runs against the wrong + * stream, and piping stdout while stderr stays a TTY (`supabase status | less`) + * would corrupt the piped output with ANSI escapes (the same bug class CLI-1546 + * fixed for the progress spinner). * * lipgloss colour "14" is bright cyan; `"cyan"` is the closest faithful match, * matching `branches.prompt.ts`'s existing port of `utils.Aqua`. */ -export function legacyAqua(text: string): string { - return styleText("cyan", text, { stream: process.stderr }); +export function legacyAqua(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("cyan", text, { stream }); } -export function legacyBold(text: string): string { - return styleText("bold", text, { stream: process.stderr }); +export function legacyBold(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("bold", text, { stream }); } /** Port of Go's `utils.Yellow` — lipgloss colour "11" (bright yellow). */ -export function legacyYellow(text: string): string { - return styleText("yellow", text, { stream: process.stderr }); +export function legacyYellow(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("yellow", text, { stream }); } /** Port of Go's `utils.Red` — lipgloss colour "9" (bright red). */ -export function legacyRed(text: string): string { - return styleText("red", text, { stream: process.stderr }); +export function legacyRed(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("red", text, { stream }); +} + +/** Port of Go's `utils.Green` — lipgloss colour "10" (bright green). */ +export function legacyGreen(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("green", text, { stream }); } diff --git a/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts new file mode 100644 index 0000000000..e1f5e7873b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { legacyAqua, legacyBold, legacyGreen, legacyRed, legacyYellow } from "./legacy-colors.ts"; + +// These tests only assert that each helper runs without throwing and returns a +// string containing the input text — actual color application depends on the +// stream's live TTY/NO_COLOR state, which isn't controllable from a test +// process. The behavior worth protecting here is the `stream` parameter +// threading through to `styleText`, not a specific ANSI byte sequence. +describe("legacy-colors", () => { + it("legacyAqua defaults to stderr when no stream is given", () => { + expect(legacyAqua("supabase")).toContain("supabase"); + }); + + it("legacyAqua accepts an explicit stream", () => { + expect(legacyAqua("supabase", process.stdout)).toContain("supabase"); + }); + + it("legacyBold defaults to stderr when no stream is given", () => { + expect(legacyBold("text")).toContain("text"); + }); + + it("legacyBold accepts an explicit stream", () => { + expect(legacyBold("text", process.stdout)).toContain("text"); + }); + + it("legacyYellow defaults to stderr when no stream is given", () => { + expect(legacyYellow("warning")).toContain("warning"); + }); + + it("legacyYellow accepts an explicit stream", () => { + expect(legacyYellow("warning", process.stdout)).toContain("warning"); + }); + + it("legacyRed defaults to stderr when no stream is given", () => { + expect(legacyRed("error")).toContain("error"); + }); + + it("legacyRed accepts an explicit stream", () => { + expect(legacyRed("error", process.stdout)).toContain("error"); + }); + + it("legacyGreen defaults to stderr when no stream is given", () => { + expect(legacyGreen("label")).toContain("label"); + }); + + it("legacyGreen accepts an explicit stream", () => { + expect(legacyGreen("label", process.stdout)).toContain("label"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 3bf5f4244f..20a7a20654 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Data, Effect } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -14,6 +14,36 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner type Spawner = ChildProcessSpawner["Service"]; +/** + * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither + * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. + * Not exported: callers never need to match on this type directly, they fold it + * into their own tagged error via {@link legacyDescribeContainerCliFailure} so + * the "no runtime found" root cause survives instead of collapsing into a + * generic "failed to ..." message. + */ +class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( + "LegacyContainerRuntimeNotFoundError", +)<{ + readonly message: string; +}> {} + +const RUNTIME_NOT_FOUND_MESSAGE = + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; + +/** + * Renders a caller-facing suffix for a `spawnContainerCli`/`containerCliExitCode` + * failure: the clear "neither runtime found" message when that's the cause, + * otherwise the underlying cause's own message (falling back to `String(cause)` + * for non-`Error` causes) so callers never collapse a real failure reason into a + * bare, uninformative "failed to ..." string. + */ +export function legacyDescribeContainerCliFailure(cause: unknown): string { + if (cause instanceof LegacyContainerRuntimeNotFoundError) return cause.message; + if (cause instanceof Error) return cause.message; + return String(cause); +} + /** * Spawn a container-CLI command and return the process handle. Use when the * caller needs to read stdout/stderr or await the exit code itself. @@ -25,7 +55,19 @@ export const spawnContainerCli = ( ) => spawner .spawn(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.spawn(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .spawn(ChildProcess.make("podman", args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); /** * Run a container-CLI command and resolve to its exit code, mirroring the @@ -38,4 +80,16 @@ export const containerCliExitCode = ( ) => spawner .exitCode(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.exitCode(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .exitCode(ChildProcess.make("podman", args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index 7c6f97aeca..e10297b8e2 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -2,9 +2,19 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { containerCliExitCode, spawnContainerCli } from "./legacy-container-cli.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "./legacy-container-cli.ts"; -function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode?: number } = {}) { +function mockSpawner( + opts: { + readonly dockerMissing?: boolean; + readonly bothMissing?: boolean; + readonly exitCode?: number; + } = {}, +) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -13,13 +23,13 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ command: cmd, args }); - if (opts.dockerMissing && cmd === "docker") { + if ((opts.dockerMissing && cmd === "docker") || opts.bothMissing === true) { return yield* Effect.fail( PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn", - description: "docker not found", + description: `${cmd} not found`, }), ); } @@ -98,4 +108,37 @@ describe("containerCliExitCode", () => { }), ); }); + + it.live("fails with a clear message when neither docker nor podman can be spawned", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["image", "inspect", "img"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toBe( + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH", + ); + }), + ); + }); +}); + +describe("legacyDescribeContainerCliFailure", () => { + it.live("describes a both-runtimes-missing failure with its clear message", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["ps"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toContain("docker: command not found"); + }), + ); + }); + + it("falls back to an Error instance's own message", () => { + expect(legacyDescribeContainerCliFailure(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error cause", () => { + expect(legacyDescribeContainerCliFailure("boom")).toBe("boom"); + expect(legacyDescribeContainerCliFailure(42)).toBe("42"); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 41a5e74b14..540029737b 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -52,3 +52,50 @@ export function localDbContainerId(projectId: string) { export function localNetworkId(projectId: string) { return localDockerId("network", projectId); } + +/** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the + * Docker label every container/volume/network created by `supabase start` carries. */ +export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; + +/** + * Go's `utils.GetDockerIds()` (`apps/cli-go/internal/utils/config.go:82-98`) — the + * 13 service container ids (excludes `db`, `network`, and the `differ` shadow + * container, which are not part of the "expected running services" set). Order and + * alias-name strings are taken verbatim from `config.go:36-49,61-79`. + */ +export function legacyServiceContainerIds(projectId: string): ReadonlyArray { + return [ + localDockerId("kong", projectId), + localDockerId("auth", projectId), + localDockerId("inbucket", projectId), + localDockerId("realtime", projectId), + localDockerId("rest", projectId), + localDockerId("storage", projectId), + localDockerId("imgproxy", projectId), + localDockerId("pg_meta", projectId), + localDockerId("studio", projectId), + localDockerId("edge_runtime", projectId), + localDockerId("analytics", projectId), + localDockerId("vector", projectId), + localDockerId("pooler", projectId), + ]; +} + +/** + * Go's `utils.CliProjectFilter` (`apps/cli-go/internal/utils/docker.go:148-156`) — + * the value that follows `--filter label=` on the `docker`/`podman` CLI. An empty + * `projectId` (Go's `--all` path) filters on the bare label across every project. + * + * Deliberately unsanitized, matching Go exactly: `CliProjectFilter` interpolates + * `projectId` into the filter string raw, with none of `GetId`'s (this file's + * `sanitizeProjectId`) character-stripping — that sanitization only applies to + * Go's own generated container *names*, never to a user-supplied `--project-id` + * filter value. There is no injection risk from skipping it here: this value is + * always passed as a single argv element to a spawned process (never through a + * shell), so a malformed value can only make Docker's own filter parsing reject + * it or match nothing — it cannot break out into another command. + */ +export function legacyCliProjectFilterValue(projectId: string): string { + if (projectId.length === 0) return LEGACY_CLI_PROJECT_LABEL; + return `${LEGACY_CLI_PROJECT_LABEL}=${projectId}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index ff967f18b8..ba33e89607 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { legacyResolveLocalProjectId, localDbContainerId } from "./legacy-docker-ids.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "./legacy-docker-ids.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -23,3 +29,40 @@ describe("legacyResolveLocalProjectId", () => { expect(localDbContainerId(id)).toBe("supabase_db_env-id"); }); }); + +describe("legacyServiceContainerIds", () => { + it("returns the 13 service container ids in Go's GetDockerIds() order", () => { + // apps/cli-go/internal/utils/config.go:82-98 — kong, auth, inbucket, realtime, + // rest, storage, imgproxy, pg_meta, studio, edge_runtime, analytics, vector, pooler. + expect(legacyServiceContainerIds("my-app")).toEqual([ + "supabase_kong_my-app", + "supabase_auth_my-app", + "supabase_inbucket_my-app", + "supabase_realtime_my-app", + "supabase_rest_my-app", + "supabase_storage_my-app", + "supabase_imgproxy_my-app", + "supabase_pg_meta_my-app", + "supabase_studio_my-app", + "supabase_edge_runtime_my-app", + "supabase_analytics_my-app", + "supabase_vector_my-app", + "supabase_pooler_my-app", + ]); + }); + + it("sanitizes the project id the same way as localDbContainerId", () => { + const ids = legacyServiceContainerIds("My App!!"); + expect(ids[0]).toBe("supabase_kong_My_App_"); + }); +}); + +describe("legacyCliProjectFilterValue", () => { + it("returns the bare label when the project id is empty (Go's --all path)", () => { + expect(legacyCliProjectFilterValue("")).toBe(LEGACY_CLI_PROJECT_LABEL); + }); + + it("returns label=projectId when a project id is given", () => { + expect(legacyCliProjectFilterValue("my-app")).toBe(`${LEGACY_CLI_PROJECT_LABEL}=my-app`); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts new file mode 100644 index 0000000000..2a89464e10 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -0,0 +1,240 @@ +import { Data, Effect, Stream } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Listing containers or volumes by Docker label failed. Wraps Go's + * `Docker.ContainerList`/`Docker.VolumeList` errors (`docker.go:99-104`, + * `docker.go:334-336` — see `checkServiceHealth`/`DockerRemoveAll`), which Go + * wraps as `"failed to list containers: %w"` / equivalent. + */ +export class LegacyDockerLifecycleListError extends Data.TaggedError( + "LegacyDockerLifecycleListError", +)<{ + readonly message: string; +}> {} + +/** Inspecting a single container's state failed for a reason other than "not found". */ +export class LegacyDockerLifecycleInspectError extends Data.TaggedError( + "LegacyDockerLifecycleInspectError", +)<{ + readonly message: string; +}> {} + +function collectByteStream(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +function splitNonEmptyLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function isMissingContainerError(stderr: string): boolean { + return stderr.toLowerCase().includes("no such container"); +} + +/** + * Go's `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` + * (`docker.go:99-104`, `status.go:126-131`) via `docker ps --filter + * label=`. `all: false` mirrors `status`'s running-only list; + * `all: true` mirrors `stop`'s "every container regardless of state" list. + */ +export const legacyListContainersByLabel = ( + spawner: Spawner, + opts: { + readonly projectIdFilter: string; + readonly all: boolean; + readonly format: "id" | "names"; + }, +) => + Effect.scoped( + Effect.gen(function* () { + const formatArg = opts.format === "names" ? "{{.Names}}" : "{{.ID}}"; + const args = [ + "ps", + "--filter", + `label=${opts.projectIdFilter}`, + ...(opts.all ? ["--all"] : []), + "--format", + formatArg, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all([ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ]).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list containers" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 + ? `failed to list containers: ${message}` + : "failed to list containers", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); + +/** + * Go's `Docker.ContainerInspect(ctx, containerId)` (`docker.go:148`, + * `status.go:148-155`) via `docker container inspect --format + * {{json .State}}`. A "no such container" stderr resolves to the literal + * `"absent"`, mirroring `errdefs.IsNotFound(err)` — every other non-zero exit + * propagates as `LegacyDockerLifecycleInspectError`, matching Go's + * `assertContainerHealthy`, which does not special-case any other inspect failure. + */ +export const legacyInspectContainerState = (spawner: Spawner, containerId: string) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + ["container", "inspect", containerId, "--format", "{{json .State}}"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleInspectError({ + message: `failed to inspect container health: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all([ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ]).pipe( + Effect.mapError( + () => + new LegacyDockerLifecycleInspectError({ + message: "failed to inspect container health", + }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + if (isMissingContainerError(message)) { + return "absent" as const; + } + return yield* Effect.fail( + new LegacyDockerLifecycleInspectError({ + message: + message.length > 0 + ? `failed to inspect container health: ${message}` + : "failed to inspect container health", + }), + ); + } + return parseContainerState(stdout); + }), + ); + +function parseContainerState(stdout: string): { + readonly running: boolean; + readonly status: string; + readonly health?: string; +} { + const trimmed = stdout.trim(); + let parsed: unknown; + try { + parsed = trimmed.length > 0 ? JSON.parse(trimmed) : {}; + } catch { + parsed = {}; + } + const state = isJsonRecord(parsed) ? parsed : {}; + const status = typeof state["Status"] === "string" ? state["Status"] : ""; + const running = status === "running"; + const health = state["Health"]; + const healthStatus = + isJsonRecord(health) && typeof health["Status"] === "string" ? health["Status"] : undefined; + return healthStatus !== undefined + ? { running, status, health: healthStatus } + : { running, status }; +} + +function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} + +/** + * Go's `Docker.VolumeList(ctx, volume.ListOptions{Filters})` + * (`docker.go` — used by the `stop` post-run volume-suggestion check) via + * `docker volume ls --filter label=`. + */ +export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: string) => + Effect.scoped( + Effect.gen(function* () { + const args = [ + "volume", + "ls", + "--filter", + `label=${projectIdFilter}`, + "--format", + "{{.Name}}", + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all([ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ]).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list volumes" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 ? `failed to list volumes: ${message}` : "failed to list volumes", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts new file mode 100644 index 0000000000..29704048c4 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + LegacyDockerLifecycleInspectError, + LegacyDockerLifecycleListError, + legacyInspectContainerState, + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "./legacy-docker-lifecycle.ts"; + +function mockSpawner( + opts: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + } = {}, +) { + const encoder = new TextEncoder(); + const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(opts.stdout !== undefined ? [encoder.encode(opts.stdout)] : []), + stderr: Stream.fromIterable(opts.stderr !== undefined ? [encoder.encode(opts.stderr)] : []), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +describe("legacyListContainersByLabel", () => { + it.live("returns container ids for a successful listing", () => { + const mock = mockSpawner({ stdout: "abc123\ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project=my-app", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.ID}}", + ], + }, + ]); + }), + ); + }); + + it.live("passes --all and requests names when configured", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "names", + }).pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.Names}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no containers match", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual([]); + }), + ); + }); + + it.live("filters out blank lines from the trimmed output", () => { + const mock = mockSpawner({ stdout: "abc123\n\n \ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe( + "failed to list containers: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list containers"); + }), + ); + }); +}); + +describe("legacyInspectContainerState", () => { + it.live("parses a running, healthy container's state", () => { + const mock = mockSpawner({ + stdout: JSON.stringify({ Status: "running", Health: { Status: "healthy" } }), + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running", health: "healthy" }); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: ["container", "inspect", "supabase_db_my-app", "--format", "{{json .State}}"], + }, + ]); + }), + ); + }); + + it.live("parses a running container with no health check configured", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "running" }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running" }); + }), + ); + }); + + it.live("parses a stopped/exited container", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "exited" }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "exited" }); + }), + ); + }); + + it.live('resolves to "absent" when the container does not exist', () => { + const mock = mockSpawner({ + exitCode: 1, + stderr: "Error: No such container: supabase_db_my-app\n", + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toBe("absent"); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleInspectError on any other inspect failure", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live( + "fails with LegacyDockerLifecycleInspectError with a generic message when stderr is empty", + () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe("failed to inspect container health"); + }), + ); + }, + ); + + it.live("treats empty inspect output as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); + + it.live("treats non-object inspect JSON as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "null" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); +}); + +describe("legacyListVolumesByLabel", () => { + it.live("returns volume names for a successful listing", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project=my-app").pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "volume", + "ls", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.Name}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no volumes remain", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.map((names) => { + expect(names).toEqual([]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "boom\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes: boom"); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes"); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts new file mode 100644 index 0000000000..7c6c75d178 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -0,0 +1,38 @@ +import { createHmac } from "node:crypto"; + +/** + * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` + * keys, ported from `CustomClaims`/`generateJWT` (`apps/cli-go/pkg/config/apikeys.go:23-40,75-86`). + * + * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` + * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, + * a dynamic `iat`/10-year `exp`, and a different claim order, none of which + * byte-match what Go prints for `supabase status`. Go's claims, in + * declaration order (the outer `CustomClaims.Issuer` field shadows the + * embedded `jwt.RegisteredClaims.Issuer`, so only one `iss` key is emitted): + * + * iss (fixed "supabase-demo"), ref (omitempty), role, is_anonymous (omitempty), + * then the remaining `jwt.RegisteredClaims` fields (sub, aud, exp, nbf, iat, jti), + * all `omitempty` except `exp`, which Go always sets to the fixed + * `defaultJwtExpiry = 1983812996` unix timestamp (never computed from "now"). + * + * `status` never sets `ref`/`is_anonymous`, so for this signer's two roles the + * payload always serializes to exactly `{"iss":...,"role":...,"exp":...}`. + */ + +const GO_JWT_ISSUER = "supabase-demo"; +const GO_JWT_FIXED_EXP = 1983812996; + +function base64UrlEncode(input: string): string { + return Buffer.from(input).toString("base64url"); +} + +export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role"): string { + const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: GO_JWT_FIXED_EXP }), + ); + const data = `${header}.${payload}`; + const signature = createHmac("sha256", secret).update(data).digest("base64url"); + return `${data}.${signature}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts new file mode 100644 index 0000000000..3c8aa39b84 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -0,0 +1,65 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; + +const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; + +function decodeSegment(segment: string): string { + return Buffer.from(segment, "base64url").toString("utf8"); +} + +describe("legacyGenerateGoJwt", () => { + it("emits Go's exact JWT header (no extra fields, alg before typ)", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header] = token.split("."); + expect(header).toBeDefined(); + // Go's jwt.NewWithClaims builds Header as map[string]any{"typ":..,"alg":..}; + // encoding/json marshals map keys in sorted order, so "alg" sorts before "typ". + expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); + }); + + it("emits the anon payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [, payload] = token.split("."); + expect(payload).toBeDefined(); + const raw = decodeSegment(payload ?? ""); + // Byte-exact key order: iss, role, exp — ref/is_anonymous/iat are omitted + // entirely (Go's `omitempty`), matching status's no-ref, non-anonymous use. + expect(raw).toBe('{"iss":"supabase-demo","role":"anon","exp":1983812996}'); + + const parsed = JSON.parse(raw) as Record; + expect(parsed).toEqual({ iss: "supabase-demo", role: "anon", exp: 1983812996 }); + expect(Object.keys(parsed)).not.toContain("iat"); + expect(Object.keys(parsed)).not.toContain("ref"); + expect(Object.keys(parsed)).not.toContain("is_anonymous"); + }); + + it("emits the service_role payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "service_role"); + const [, payload] = token.split("."); + const raw = decodeSegment(payload ?? ""); + expect(raw).toBe('{"iss":"supabase-demo","role":"service_role","exp":1983812996}'); + }); + + it("signs with plain HMAC-SHA256 over the base64url header.payload, base64url-encoded", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header, payload, signature] = token.split("."); + const expectedSignature = createHmac("sha256", SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(signature).toBe(expectedSignature); + }); + + it("is deterministic across calls (no timestamp derived from Date.now())", () => { + const first = legacyGenerateGoJwt(SECRET, "anon"); + const second = legacyGenerateGoJwt(SECRET, "anon"); + expect(first).toBe(second); + }); + + it("produces different tokens for different secrets", () => { + const a = legacyGenerateGoJwt(SECRET, "anon"); + const b = legacyGenerateGoJwt("a-different-secret-value-1234567", "anon"); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts new file mode 100644 index 0000000000..2c6b4fac51 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -0,0 +1,111 @@ +import type { ProjectConfig } from "@supabase/config"; +import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; + +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; + +/** + * Go-parity derived local-dev config values, ported from `utils.Config`'s + * post-load defaulting (`pkg/config/config.go:406-441,748-758`) and + * `utils.GetApiUrl`/status's `toValues()` (`internal/utils/config.go:255-268`, + * `internal/status/status.go:52-95`). `@supabase/config`'s schema has no field for + * a handful of Go constants (`db.password`, the S3 credential triple) — those are + * Go-hardcoded literals, reproduced here rather than added to the shared schema + * (`pkg/config/config.go:408,437-441`). + * + * Kept generic (no `status`-specific shaping) so a future native `start`/`restart` + * port can reuse it instead of re-deriving these values — see the plan's + * "Files to create" note. Do not fold this into `legacy-storage-credentials.ts`; + * that module resolves credentials through a different (HTTP/tenant-aware) path + * for the remote-project branch, which this pure resolver does not need (the + * shared `://:` derivation itself lives in + * `legacy-api-url.ts`, used by both). + */ + +/** Go's `Db.Password` default (`pkg/config/config.go:408`) — never present in config.toml. */ +const DEFAULT_DB_PASSWORD = "postgres"; + +/** Go's hardcoded local S3 credentials (`pkg/config/config.go:437-441`). */ +const DEFAULT_S3_ACCESS_KEY_ID = "625729a08b95bf1b7ff351a663f3a23c"; +const DEFAULT_S3_SECRET_ACCESS_KEY = + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"; +const DEFAULT_S3_REGION = "local"; + +export interface LegacyLocalConfigValues { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly functionsUrl: string; + readonly mcpUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly jwtSecret: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly storageS3Url: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Go's `utils.GetApiUrl(path)` (`internal/utils/config.go:255-268`): appends + * `path` to the resolved external URL. Go's own fallback branch (building a bare + * `http://host:port` when `Config.Api.ExternalUrl` is empty) is unreachable in + * practice because `config.Load` already defaults `ExternalUrl` before `status` + * runs — `resolveApiExternalUrl` reproduces that same default, so `apiExternalUrl` + * passed in here is never empty. + */ +function apiUrlWithPath(apiExternalUrl: string, path: string): string { + return `${apiExternalUrl}${path}`; +} + +/** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ +function resolveJwtSecret(configured: string | undefined): string { + return configured !== undefined && configured.length > 0 ? configured : defaultJwtSecret; +} + +function resolveOpaqueKey(configured: string | undefined, fallback: string): string { + return configured !== undefined && configured.length > 0 ? configured : fallback; +} + +function resolveSignedKey( + configured: string | undefined, + jwtSecret: string, + role: "anon" | "service_role", +): string { + return configured !== undefined && configured.length > 0 + ? configured + : legacyGenerateGoJwt(jwtSecret, role); +} + +export function legacyResolveLocalConfigValues( + config: ProjectConfig, + hostname: string, +): LegacyLocalConfigValues { + const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); + const jwtSecret = resolveJwtSecret(config.auth.jwt_secret); + + return { + apiUrl: apiExternalUrl, + restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), + graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), + functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), + mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), + studioUrl: `http://${hostname}:${config.studio.port}`, + mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, + publishableKey: resolveOpaqueKey(config.auth.publishable_key, defaultPublishableKey), + secretKey: resolveOpaqueKey(config.auth.secret_key, defaultSecretKey), + jwtSecret, + anonKey: resolveSignedKey(config.auth.anon_key, jwtSecret, "anon"), + serviceRoleKey: resolveSignedKey(config.auth.service_role_key, jwtSecret, "service_role"), + storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), + storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, + storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, + storageS3Region: DEFAULT_S3_REGION, + }; +} diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts new file mode 100644 index 0000000000..0abfb35a57 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -0,0 +1,110 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +describe("legacyResolveLocalConfigValues", () => { + it("derives every URL from api.external_url when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); + expect(values.graphqlUrl).toBe("http://127.0.0.1:54321/graphql/v1"); + expect(values.functionsUrl).toBe("http://127.0.0.1:54321/functions/v1"); + expect(values.mcpUrl).toBe("http://127.0.0.1:54321/mcp"); + expect(values.storageS3Url).toBe("http://127.0.0.1:54321/storage/v1/s3"); + expect(values.studioUrl).toBe("http://127.0.0.1:54323"); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54324"); + }); + + it("uses https and the configured port when api.tls.enabled", () => { + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + 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 = legacyResolveLocalConfigValues(config, "::1"); + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + 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"); + }); + + it("uses configured opaque keys verbatim when set", () => { + const config = baseConfig({ + auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we + // only assert the resolver wires the default secret through to both roles. + const [, anonPayload] = values.anonKey.split("."); + const [, serviceRolePayload] = values.serviceRoleKey.split("."); + expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ + role: "anon", + }); + expect(JSON.parse(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( + { role: "service_role" }, + ); + }); + + it("uses configured anon/service_role keys verbatim when set", () => { + const config = baseConfig({ + auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.jwtSecret).toBe("a".repeat(32)); + expect(values.anonKey).not.toBe(""); + }); + + it("hardcodes the Go-parity local S3 credentials", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); + expect(values.storageS3SecretAccessKey).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ); + expect(values.storageS3Region).toBe("local"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 0826a7de8f..21983e8d15 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { legacyMapTenantApiKeysError } from "./legacy-get-tenant-api-keys.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { legacyExtractServiceKeys } from "./legacy-tenant-keys.ts"; @@ -122,23 +123,11 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts }); /** - * Local API URL, mirroring Go's `config.go:634-644` + `misc.go:298`: an explicit - * `api.external_url` wins, otherwise `://:` where the scheme - * follows `api.tls.enabled`, the host is `legacyGetHostname` (Go's - * `utils.GetHostname`), and the port is `api.port`. + * Local API URL: `legacyResolveApiExternalUrl` with `legacyGetHostname` (Go's + * `utils.GetHostname`) supplying the host when `api.external_url` is unset. */ function resolveLocalBaseUrl(config: LegacyStorageConfigView): string { - if (config.api.external_url !== undefined && config.api.external_url.length > 0) { - return config.api.external_url; - } - const host = legacyGetHostname(); - const scheme = config.api.tls.enabled ? "https" : "http"; - // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an - // IPv6 host. legacyGetHostname returns the unbracketed host, so bracket here. - const hostPort = host.includes(":") - ? `[${host}]:${config.api.port}` - : `${host}:${config.api.port}`; - return `${scheme}://${hostPort}`; + return legacyResolveApiExternalUrl(config.api, legacyGetHostname()); } /** diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 8dc2efe194..3020fe3c1d 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -123,10 +123,15 @@ describe("native hidden flags", () => { Effect.scoped( Effect.gen(function* () { yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["start", "--preview"]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + // `stop` is natively ported (no longer a `LegacyGoProxy` forward), so it can fail for + // Docker-related reasons in this proxy-only test layer — the point here is only to + // prove the hidden `--backup` flag still parses by exact name, not that the command + // succeeds, matching the `functions deploy`/`serve` assertions below. + const stopExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "stop", "--backup=false", - ]); + ]).pipe(Effect.exit); + expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", "download", @@ -162,7 +167,6 @@ describe("native hidden flags", () => { expect(proxy.calls).toEqual([ ["start", "--preview"], - ["stop", "--backup=false"], ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], ]); }); From 491f0529fe727c7ee9b6bc4294476d28dcc34f35 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 11:12:10 +0100 Subject: [PATCH 02/81] test(cli): add live tests for supabase stop/status and document *.live.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the *.live.test.ts convention in AGENTS.md (a 4th test category alongside unit/integration/e2e): black-box CLI subprocess tests executed by the cli-e2e-ci harness against a real supabox stack. Clarifies how local-dev-stack commands like stop/status fit this pattern despite never calling the Management API — they only need the real Docker daemon the cli-e2e-ci runner also provides, so they reuse the existing describeLive gate rather than a dedicated one. Adds stop.live.test.ts and status.live.test.ts, each spinning up a real local Docker stack (init -> start, excluding the heaviest services) in an isolated temp directory and verifying the command under test against the real containers, with best-effort cleanup on every path. --- apps/cli/AGENTS.md | 16 ++++ .../commands/status/status.live.test.ts | 54 +++++++++++++ .../legacy/commands/stop/stop.live.test.ts | 78 +++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 apps/cli/src/legacy/commands/status/status.live.test.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.live.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index c30465cd85..f16021bf07 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -402,6 +402,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. ### Testing policy @@ -416,6 +417,21 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. - **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. +### Live tests (`*.live.test.ts`) + +Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. + +- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. +- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. +- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: + - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal, and there is no dedicated Docker-availability gate today. + - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). + - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). +- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. +- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. +- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. +- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). + --- ## Go CLI Parity Tracking diff --git a/apps/cli/src/legacy/commands/status/status.live.test.ts b/apps/cli/src/legacy/commands/status/status.live.test.ts new file mode 100644 index 0000000000..64569c118c --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.live.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const START_TIMEOUT_MS = 280_000; + +// See stop.live.test.ts for why `describeLive` (not a Management-API gate) is +// the right reuse here: `status` never calls the Management API, only the real +// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "Live tests" +// section for the full convention. +describeLive("supabase status (live)", () => { + let projectDir: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "reports a running local stack in pretty and json modes", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-status-live-")); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const pretty = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); + expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); + expect(pretty.stdout).toContain("Project URL"); + expect(pretty.stdout).toContain("Database"); + + const json = await runSupabaseLive(["status", "-o", "json"], { cwd: projectDir }); + expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); + const parsed: unknown = JSON.parse(json.stdout); + expect(parsed).toMatchObject({ + API_URL: expect.stringContaining("http"), + DB_URL: expect.stringContaining("postgresql://"), + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts new file mode 100644 index 0000000000..95452c78c7 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.live.test.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const execFileAsync = promisify(execFile); + +const START_TIMEOUT_MS = 280_000; + +// `stop` never calls the Management API — it talks directly to the real local +// Docker stack `start` (still a Go-proxy) creates. `describeLive` is reused +// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a +// real Docker daemon, since that's how supabox itself runs); the +// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See +// AGENTS.md's "Live tests" section for the full convention. +describeLive("supabase stop (live)", () => { + let projectDir: string | undefined; + let projectId: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + projectId = undefined; + }); + + test( + "starts a real local stack, then stops it and removes its containers", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); + // No `project_id` override, so the cli resolves it from the workdir + // basename — matching Go's precedence exactly (see legacy-docker-ids.ts). + projectId = path.basename(projectDir); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // Exclude the heaviest, least relevant services (Next.js Studio build, the + // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care + // which services are running, only that at least one real container + // exists to stop. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + // Sanity: confirm the stack is actually up before testing `stop` against it. + const before = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + + const stop = await runSupabaseLive(["stop"], { cwd: projectDir }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // The real Docker daemon must agree: no container carrying this project's + // label survives `stop` — the actual behavior under test, not just the + // cli's own exit code. + const { stdout: remaining } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ]); + expect(remaining.trim()).toBe(""); + }, + ); +}); From deaf1e66020c59caa8adc36d391c3a9e25a04983 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 12:13:37 +0100 Subject: [PATCH 03/81] fix(cli): address Go-parity review findings on stop/status (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accepted findings from PR review, each verified against Go source before implementing: - Sanitize the config/env-derived project id before building the Docker label filter in both `stop` and `status`. Go's `Config.Validate` sanitizes `Config.ProjectId` once at config-load time (pkg/config/config.go:938-944), and every later reader — including the Docker label `start` writes (internal/utils/docker.go:375) — sees that same sanitized string. Without this, a dirty `project_id` (spaces, leading punctuation) would filter on a value `start` never labeled, silently matching nothing. The explicit `--project-id` bypass on `stop` stays raw, matching Go's stop.go:19-20. - `status --override-name` with an unrecognized field key is now silently ignored instead of a hard error, matching Go's `go-env` Unmarshal, which never checks its input map for unmatched keys (verified against the vendored go-env@v0.1.2 source). - `status` no longer hard-fails when `supabase/config.toml` is absent; Go's `flags.LoadConfig` treats a missing file as a no-op and proceeds with template defaults (pkg/config/config.go:655-656), so this now decodes an empty document through the shared config schema for its defaults instead of erroring. (The broader gap — Go's automatic `SUPABASE_` env-var binding via viper, which `@supabase/config` doesn't have an equivalent for — is a larger, cross-cutting `@supabase/config` feature affecting every ported command, called out as a follow-up rather than folded into this fix.) Two other findings were investigated and rejected with cited evidence (directly on the PR): a claimed `--backup`/`--no-backup` flag collision (empirically disproven against Effect's parser), and image-name-based `--exclude` matching (the underlying config.toml schema has no field to check, on either CLI). --- .../legacy/commands/status/status.handler.ts | 58 ++++++++------ .../status/status.integration.test.ts | 78 +++++++++++++++---- .../src/legacy/commands/stop/stop.handler.ts | 14 +++- .../commands/stop/stop.integration.test.ts | 46 +++++++++++ .../src/legacy/shared/legacy-docker-ids.ts | 46 ++++++++--- .../shared/legacy-docker-ids.unit.test.ts | 33 ++++++++ 6 files changed, 223 insertions(+), 52 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 2444a07d54..21a4026add 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,6 +1,6 @@ -import { loadProjectConfig } from "@supabase/config"; +import { loadProjectConfig, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, Option } from "effect"; +import { Effect, Option, Schema } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; @@ -10,6 +10,7 @@ import { legacyAqua } from "../../shared/legacy-colors.ts"; import { legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, } from "../../shared/legacy-docker-ids.ts"; @@ -44,7 +45,13 @@ import { * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a * `fieldKey -> outputName` map, mirroring Go's `env.EnvironToEnvSet` + * `env.Unmarshal` (`cmd/status.go:21-27`): each entry must be a `KEY=VALUE` - * pair whose `KEY` matches one of the 18 known `CustomName` field keys. + * pair. `env.EnvironToEnvSet` only validates that shape (`go-env`'s + * `ErrInvalidEnviron`); the Netflix `go-env` library's `Unmarshal` then walks + * `CustomName`'s own struct fields and looks up each field's tag in the + * resulting map — it never checks the map for leftover/unmatched keys, so an + * entry whose `KEY` isn't one of the 18 known `CustomName` field keys is + * silently ignored, not an error (verified against `go-env@v0.1.2`'s + * `env.go`/`transform.go`). */ function parseOverrides( entries: ReadonlyArray, @@ -63,11 +70,7 @@ function parseOverrides( const key = entry.slice(0, separatorIndex); const value = entry.slice(separatorIndex + 1); if (!knownKeys.has(key)) { - return Effect.fail( - new LegacyStatusOverrideParseError({ - message: `unknown override-name key: ${key}`, - }), - ); + continue; } overrides.set(key, value); } @@ -87,27 +90,36 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; yield* Effect.gen(function* () { - // 1. `status` always needs config, unlike `stop` (status.go:99-103). + // 1. `status` always needs config, unlike `stop` (status.go:99-103). An + // ABSENT config.toml is not a hard failure in Go: `flags.LoadConfig` -> + // `Config.Load` -> `loadFromFile` -> `mergeFileConfig` treats a missing + // file as a no-op (`os.ErrNotExist` -> nil, pkg/config/config.go:655-656) + // and proceeds with template defaults (`mergeDefaultValues`, + // pkg/config/config.go:639-648). Only a MALFORMED file is a hard error. + // Mirror that by decoding an empty document through the schema for its + // defaults (matching `packages/config/src/functions-manifest.ts`'s + // `decodeProjectConfig({})` pattern) instead of failing. const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( Effect.mapError( (cause) => new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); - if (loaded === null) { - return yield* Effect.fail( - new LegacyStatusConfigLoadError({ - message: "failed to read config: supabase/config.toml not found", - }), - ); - } - const config = loaded.config; - - // 2. status has no --project-id flag; resolution is always env → toml → workdir basename. - const projectId = legacyResolveLocalProjectId( - process.env["SUPABASE_PROJECT_ID"], - config.project_id, - cliConfig.workdir, + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // 2. status has no --project-id flag; resolution is always env → toml → + // workdir basename, then sanitized to match the singleton Go's + // `Config.Validate` produces once at config-load time + // (`pkg/config/config.go:938-944`) — every reader, including the Docker + // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same + // sanitized string, so `status` must filter on it too (see + // `legacyCliProjectFilterValue`'s doc comment). + const projectId = legacySanitizeProjectId( + legacyResolveLocalProjectId( + process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ), ); const dbContainerId = localDbContainerId(projectId); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index ac39129679..28fa703817 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -208,6 +208,27 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering/inspecting with the raw value here would target + // containers `start` never created. + const { layer, child } = setup({ configContents: 'project_id = "My App!!"\n' }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args[2]).toBe(localDbContainerId("My_App_")); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=My_App_"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("skips the db health check with --ignore-health-check", () => { const { layer, child } = setup({ route: (args) => { @@ -251,14 +272,27 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when config.toml is missing entirely", () => { - const { layer } = setup({ skipConfig: true }); + it.live("reports status using schema defaults when config.toml is missing entirely", () => { + // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> + // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> + // nil, pkg/config/config.go:655-656), not an error — `status` proceeds + // using template defaults. Only a malformed file is a hard failure (see + // the sibling "malformed" test above). + // + // Without config.toml, the resolved project id falls back to the workdir + // basename (not the module-level `ALL_RUNNING_NAMES`, which is fixed to + // "demo") — route `ps` off that so the expected services actually show as + // running rather than all appearing "stopped" and excluded. + const projectId = basename(tempRoot.current); + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), + }); return Effect.gen(function* () { - const exit = yield* Effect.exit(legacyStatus(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); - } + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("Project URL"); + expect(out.stdoutText).toContain("Database"); }).pipe(Effect.provide(layer)); }); @@ -434,16 +468,28 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails on an --override-name entry with an unknown field key", () => { - const { layer } = setup(); + it.live("silently ignores an --override-name entry with an unknown field key", () => { + // Matches Go: `env.Unmarshal` (Netflix go-env) walks CustomName's own struct + // fields and looks up each field's tag in the override map — it never checks + // for leftover/unmatched keys, so an unrecognized key is a no-op, not an error. + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NAME).toBeUndefined(); + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + }).pipe(Effect.provide(layer)); + }); + + it.live("applies a valid --override-name entry alongside an unknown one", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { - const exit = yield* Effect.exit( - legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })), + yield* legacyStatus( + flags({ overrideName: ["not.a.real.field=NAME", "api.url=NEXT_PUBLIC_SUPABASE_URL"] }), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); - } + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.NAME).toBeUndefined(); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index a2e6863f0e..e6f5dcebfc 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -13,6 +13,7 @@ import { import { legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacySanitizeProjectId, } from "../../shared/legacy-docker-ids.ts"; import { legacyListContainersByLabel, @@ -35,6 +36,16 @@ import { * `--project-id` overrides `Config.ProjectId` directly, also bypassing * config.toml; otherwise `flags.LoadConfig` reads config.toml and * `Config.ProjectId` (env → toml → workdir basename) is used. + * + * The config/env-derived (default) branch is sanitized with + * {@link legacySanitizeProjectId} before it's used as a filter value, + * matching Go's `Config.Validate` sanitizing the `Config.ProjectId` + * singleton once at config-load time (`pkg/config/config.go:938-944`) — every + * later reader, including the Docker LABEL `start` writes + * (`internal/utils/docker.go:375`), sees that same sanitized string. The + * explicit `--project-id` bypass stays RAW to match: Go assigns the flag + * value straight to `Config.ProjectId` without going through `Validate` + * (`internal/stop/stop.go:19-20`). */ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { @@ -51,11 +62,12 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); - return legacyResolveLocalProjectId( + const resolved = legacyResolveLocalProjectId( process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); + return legacySanitizeProjectId(resolved); }, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 3f56eead39..eadbd06fb2 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -223,6 +223,52 @@ describe("legacy stop integration", () => { }, ); + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering on the raw value here would match nothing `start` + // ever labeled. + const { layer, child } = setup({ + configuredProjectId: "My App!!", + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=My_App_", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => { + // Go assigns the --project-id flag value straight to Config.ProjectId + // without going through Validate (internal/stop/stop.go:19-20), so this + // path must NOT sanitize even though the default (config-derived) path does. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("Raw Value!!") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=Raw Value!!", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("stops every project's containers with --all without reading config.toml", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 540029737b..93ac55723b 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -32,15 +32,30 @@ function truncateText(text: string, maxLength: number) { return text.length > maxLength ? text.slice(0, maxLength) : text; } -/** Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading - * `_.-`, and cap at 40 chars. */ -function sanitizeProjectId(src: string) { +/** + * Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading + * `_.-`, and cap at 40 chars. + * + * Exported because it is not only a container-*naming* concern: Go's + * `Config.Validate` (`pkg/config/config.go:938-944`) rewrites `c.ProjectId` + * to this same sanitized form **in place, once, at config-load time** (every + * `flags.LoadConfig` call ends in `Load` -> `Validate`), and every later use + * of `Config.ProjectId` — including the Docker LABEL value written by `start` + * (`internal/utils/docker.go:375`: `config.Labels[CliProjectLabel] = + * Config.ProjectId`) — reads that already-sanitized singleton. `GetId` itself + * performs no sanitisation of its own; it just reads the pre-sanitized value. + * So on the config/env-derived (non-`--project-id`) path, callers building a + * Docker label FILTER must sanitize too, or a `project_id` like `"my app"` + * filters on the raw string while `start` labeled the sanitized one and never + * matches anything (see `legacyCliProjectFilterValue`'s doc comment). + */ +export function legacySanitizeProjectId(src: string) { const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); } function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${sanitizeProjectId(projectId)}`; + return `supabase_${name}_${legacySanitizeProjectId(projectId)}`; } /** `utils.DbId` — the local Postgres container name. */ @@ -86,14 +101,21 @@ export function legacyServiceContainerIds(projectId: string): ReadonlyArray` (where one exists, + * e.g. `stop`) is Go's one exception: it assigns straight to + * `Config.ProjectId` without going through `Validate` + * (`apps/cli-go/internal/stop/stop.go:19-20`), so that path must stay raw/ + * unsanitized to match. There is also no injection risk either way: this + * value is always passed as a single argv element to a spawned process + * (never through a shell), so a malformed value can only make Docker's own + * filter parsing reject it or match nothing — it cannot break out into + * another command. */ export function legacyCliProjectFilterValue(projectId: string): string { if (projectId.length === 0) return LEGACY_CLI_PROJECT_LABEL; diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index ba33e89607..9c07805652 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -4,6 +4,7 @@ import { LEGACY_CLI_PROJECT_LABEL, legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, } from "./legacy-docker-ids.ts"; @@ -65,4 +66,36 @@ describe("legacyCliProjectFilterValue", () => { it("returns label=projectId when a project id is given", () => { expect(legacyCliProjectFilterValue("my-app")).toBe(`${LEGACY_CLI_PROJECT_LABEL}=my-app`); }); + + it("must be sanitized by the caller for the label to match what start wrote", () => { + // This function is a pure pass-through by design (see its doc comment) — a + // dirty config/env-derived id must be sanitized by the caller BEFORE being + // passed here, matching Go's Config.Validate sanitizing Config.ProjectId + // once at config-load time so every reader (including the Docker label + // `start` writes) sees the same string. + const dirty = "My App!!"; + expect(legacyCliProjectFilterValue(dirty)).toBe(`${LEGACY_CLI_PROJECT_LABEL}=My App!!`); + expect(legacyCliProjectFilterValue(legacySanitizeProjectId(dirty))).toBe( + `${LEGACY_CLI_PROJECT_LABEL}=My_App_`, + ); + }); +}); + +describe("legacySanitizeProjectId", () => { + it("replaces invalid character runs with a single underscore", () => { + expect(legacySanitizeProjectId("My App!!")).toBe("My_App_"); + }); + + it("strips leading underscore/dot/dash runs", () => { + expect(legacySanitizeProjectId("...hidden-app")).toBe("hidden-app"); + }); + + it("caps the result at 40 characters", () => { + const long = "a".repeat(50); + expect(legacySanitizeProjectId(long)).toBe("a".repeat(40)); + }); + + it("leaves an already-clean id unchanged", () => { + expect(legacySanitizeProjectId("my-app_123")).toBe("my-app_123"); + }); }); From d362f4188abd5e02b83371c8fec2fc6f6e7b7be0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 12:32:41 +0100 Subject: [PATCH 04/81] fix(cli): honor image-name exclusions in status (review: PRRT_kwDOErm0O86N3Lyo) Go's status.toValues() gates each service on `--exclude` matching either the container id or the Docker image's short name (ShortContainerImageName). The TS port only checked container ids. Port the short-name extraction and check it against the same default images the embedded Dockerfile manifest already provides, since the relevant Go config fields (KongImage, Image, etc.) all carry `toml:"-"` and are never user-overridable. --- .../legacy/commands/status/SIDE_EFFECTS.md | 8 +- .../legacy/commands/status/status.values.ts | 60 ++++++++++-- .../status/status.values.unit.test.ts | 95 +++++++++++++++++++ 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 4d9579644d..4fecbad100 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -157,9 +157,11 @@ Additive — no Go CLI equivalent. Emits the same resolved value map via - When neither `docker` nor `podman` can be spawned at all, the error message names the actual root cause (e.g. "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH") rather than a generic "failed to ..." string. -- `--exclude ` (hidden) omits a service from the value map by container id only — - Go additionally supports excluding by Docker image short-name, which has no `@supabase/config` - schema equivalent to check against, so that branch is not replicated (documented divergence). +- `--exclude ` (hidden) omits a service from the value map when `value` matches either its + container id or its default Docker image short name (Go's `ShortContainerImageName`, e.g. + `storage-api` for the storage service, `edge-runtime` for edge functions) — the default image + is read from the same embedded Dockerfile manifest Go parses, so a version bump there is picked + up automatically without needing to read the `.temp/-version` pin file. - `--ignore-health-check` (hidden) skips the db container health assertion entirely and always exits `0`, matching Go's early-return in `Run()`. - Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 6b6fa3d5b9..4b8c894fdc 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -1,5 +1,6 @@ import type { ProjectConfig } from "@supabase/config"; +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; import { legacyResolveLocalConfigValues, @@ -174,6 +175,33 @@ export function legacyStatusContainerIds(projectId: string): LegacyStatusContain }; } +/** + * Port of Go's `utils.ShortContainerImageName` (`internal/utils/misc.go:33-39,75`): + * extracts the repo name between the (first) `/` and the (last) `:`, falling back to + * the full string when the image ref doesn't match (no slash, or no tag). + */ +export function legacyShortContainerImageName(imageName: string): string { + const match = /\/(.*):/.exec(imageName); + return match?.[1] ?? imageName; +} + +// Default image short names Go's `--exclude` also matches against +// (`internal/status/status.go:55-61`), one per gated service. Sourced from the same +// embedded Dockerfile manifest Go parses (`dockerfileServiceImage`), so a version bump +// there is picked up automatically. Pinned-version substitution +// (`legacy-db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the +// first `:`, which `legacyShortContainerImageName` discards — so these are invariant to +// version pinning and no `.temp/-version` file needs to be read here. +const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("kong")); +const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("postgrest")); +const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("studio")); +const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("gotrue")); +const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("mailpit")); +const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("storage")); +const EDGE_RUNTIME_IMAGE_NAME = legacyShortContainerImageName( + dockerfileServiceImage("edgeruntime"), +); + export interface LegacyStatusValuesResult { readonly values: Record; readonly names: LegacyStatusOutputNames; @@ -182,8 +210,11 @@ export interface LegacyStatusValuesResult { /** * Port of Go's `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`). - * `excluded` matches by container id only — Go's `ShortContainerImageName` branch - * has no schema equivalent to check against (decision #3 in the port plan). + * `excluded` matches each gated service by its container id (`legacyStatusContainerIds`) + * OR its default Docker image short name (`shortContainerImageName` above) — the 6 + * relevant Go config fields (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, + * `Inbucket.Image`, `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're + * never user-overridable and the default image is always the one to check. */ export function legacyStatusValues( config: ProjectConfig, @@ -196,13 +227,24 @@ export function legacyStatusValues( const names = resolveOutputNames(overrides); const isExcluded = (id: string) => excluded.includes(id); - const kongEnabled = config.api.enabled && !isExcluded(containerIds.kong); - const postgrestEnabled = kongEnabled && !isExcluded(containerIds.rest); - const studioEnabled = config.studio.enabled && !isExcluded(containerIds.studio); - const authEnabled = config.auth.enabled && !isExcluded(containerIds.auth); - const inbucketEnabled = config.local_smtp.enabled && !isExcluded(containerIds.inbucket); - const storageEnabled = config.storage.enabled && !isExcluded(containerIds.storage); - const functionsEnabled = config.edge_runtime.enabled && !isExcluded(containerIds.edgeRuntime); + const kongEnabled = + config.api.enabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + const postgrestEnabled = + kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); + const studioEnabled = + config.studio.enabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); + const authEnabled = + config.auth.enabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); + const inbucketEnabled = + config.local_smtp.enabled && + !isExcluded(containerIds.inbucket) && + !isExcluded(MAILPIT_IMAGE_NAME); + const storageEnabled = + config.storage.enabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); + const functionsEnabled = + config.edge_runtime.enabled && + !isExcluded(containerIds.edgeRuntime) && + !isExcluded(EDGE_RUNTIME_IMAGE_NAME); // Go always sets db.url unconditionally, before any gating (status.go:52). const values: Record = { diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts index 152197e119..bee78682a1 100644 --- a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { + legacyShortContainerImageName, legacyStatusContainerIds, legacyStatusValues, type LegacyStatusContainerIds, @@ -72,6 +73,17 @@ describe("legacyStatusValues", () => { expect(values.API_URL).toBeUndefined(); }); + it("omits API_URL when the kong image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["kong"], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeUndefined(); + }); + it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { const config = baseConfig({ api: { enabled: false } }); const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); @@ -103,6 +115,19 @@ describe("legacyStatusValues", () => { expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); }); + + it("omits REST/GraphQL when the postgrest image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["postgrest"], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); }); describe("functions gating", () => { @@ -139,6 +164,19 @@ describe("legacyStatusValues", () => { ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); + + it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { + // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile's + // build alias (`edgeruntime`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["edge-runtime"], + NO_OVERRIDES, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); }); describe("studio / mcp gating", () => { @@ -170,6 +208,17 @@ describe("legacyStatusValues", () => { expect(values.STUDIO_URL).toBeUndefined(); }); + it("omits STUDIO_URL when the studio image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["studio"], + NO_OVERRIDES, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + it("includes MCP_URL only when both kong and studio are enabled", () => { const { values } = legacyStatusValues( baseConfig(), @@ -230,6 +279,17 @@ describe("legacyStatusValues", () => { ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); + + it("omits all 5 auth fields when the gotrue image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["gotrue"], + NO_OVERRIDES, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); }); describe("inbucket/mailpit gating", () => { @@ -262,6 +322,17 @@ describe("legacyStatusValues", () => { ); expect(values.MAILPIT_URL).toBeUndefined(); }); + + it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["mailpit"], + NO_OVERRIDES, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); }); describe("storage / s3 gating", () => { @@ -296,6 +367,19 @@ describe("legacyStatusValues", () => { expect(values.STORAGE_S3_URL).toBeUndefined(); }); + 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's + // build alias (`storage`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["storage-api"], + NO_OVERRIDES, + ); + 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 } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); @@ -372,6 +456,17 @@ describe("legacyStatusValues", () => { }); }); +describe("legacyShortContainerImageName", () => { + it("extracts the repo name between the first slash and the last colon", () => { + expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); + expect(legacyShortContainerImageName("library/kong:2.8.1")).toBe("kong"); + }); + + it("falls back to the full string when there is no slash/tag to extract", () => { + expect(legacyShortContainerImageName("kong")).toBe("kong"); + }); +}); + describe("legacyStatusContainerIds", () => { it("derives every named field from legacyServiceContainerIds's fixed array order", () => { const ids = legacyStatusContainerIds("demo"); From 4cfa1ecd93fdca1c79ac393ee2d1c198230e306a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 13:25:38 +0100 Subject: [PATCH 05/81] fix(cli): treat empty --project-id as unset in stop (review: PRRT_kwDOErm0O86N4wLX) Go's stop.Run checks len(projectId) > 0 (internal/stop/stop.go:18), not just whether --project-id was set, so an explicit but empty value falls through to config.toml resolution. The TS port only checked Option.isSome, so `--project-id ""` resolved to the bare all-projects label filter instead. --- .../src/legacy/commands/stop/stop.handler.ts | 9 ++++++++- .../commands/stop/stop.integration.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index e6f5dcebfc..0d2ef56339 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -46,11 +46,18 @@ import { * explicit `--project-id` bypass stays RAW to match: Go assigns the flag * value straight to `Config.ProjectId` without going through `Validate` * (`internal/stop/stop.go:19-20`). + * + * Go's check is `len(projectId) > 0` (`internal/stop/stop.go:18`), not merely + * "was the flag set" — an explicit but empty `--project-id ""` falls through + * to the config.toml branch exactly like an absent flag, so that's mirrored + * here with a non-empty check rather than `Option.isSome` alone. */ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { if (flags.all) return ""; - if (Option.isSome(flags.projectId)) return flags.projectId.value; + if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { + return flags.projectId.value; + } // An absent config.toml is not a failure — Go's `flags.LoadConfig` still // resolves a project id via the workdir basename default. Only a diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index eadbd06fb2..edbd1031d2 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -326,6 +326,25 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("falls back to config.toml when --project-id is an empty string", () => { + // Go's check is `len(projectId) > 0` (internal/stop/stop.go:18), not just + // "was --project-id set" — an empty value must fall through to config.toml + // exactly like an absent flag, not resolve to the bare/all-projects filter. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { From db0ea333ee335836db800dc33908937c7afbdc08 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 13:25:44 +0100 Subject: [PATCH 06/81] fix(cli): CSV-split status StringSlice flags (review: PRRT_kwDOErm0O86N4wLa, PRRT_kwDOErm0O86N4wLu) Go registers both --override-name and --exclude as pflag StringSliceVar (cmd/status.go:36-37), which CSV-splits each occurrence and accumulates across repeats. The TS flags only handled repetition, so a single comma-separated value like `--exclude kong,auth` produced one malformed entry instead of two. Reuses the shared legacyParseStringSliceFlag already applied to sso/postgres-config for the same StringSlice parity gap. --- .../legacy/commands/status/status.command.ts | 39 +++++++--- .../status/status.command.unit.test.ts | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/legacy/commands/status/status.command.unit.test.ts diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index d08c723235..12be889ebb 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -5,24 +5,43 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../shared/legacy-go-output-flag.ts"; +import { legacyParseStringSliceFlag } from "../../shared/legacy-string-slice-flag.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStatus } from "./status.handler.ts"; -const config = { - overrideName: Flag.string("override-name").pipe( - Flag.atLeast(0), - Flag.withDescription("Override specific variable names."), - Flag.withDefault([] as ReadonlyArray), - ), - exclude: Flag.string("exclude").pipe( +/** + * Go registers both `--override-name` and `--exclude` as pflag `StringSliceVar` + * (`cmd/status.go:36-37`), which CSV-splits each occurrence and accumulates + * across repeats — `--override-name a=1,b=2` is two overrides, not one. Effect's + * `Flag.atLeast(0)` only handles repetition, so every occurrence needs the same + * `legacyParseStringSliceFlag` normalization already used for `sso`/`postgres-config`. + */ +function csvStringSliceFlag(name: string) { + return Flag.string(name).pipe( Flag.atLeast(0), - Flag.withDescription("Names of containers to omit from output."), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.withDefault([] as ReadonlyArray), - Flag.withHidden, - ), + ); +} + +export const legacyStatusOverrideNameFlag = csvStringSliceFlag("override-name").pipe( + Flag.withDescription("Override specific variable names."), +); + +export const legacyStatusExcludeFlag = csvStringSliceFlag("exclude").pipe( + Flag.withDescription("Names of containers to omit from output."), + Flag.withHidden, +); + +const config = { + overrideName: legacyStatusOverrideNameFlag, + exclude: legacyStatusExcludeFlag, ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), Flag.withHidden, diff --git a/apps/cli/src/legacy/commands/status/status.command.unit.test.ts b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts new file mode 100644 index 0000000000..257f805382 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts @@ -0,0 +1,75 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacyStatusExcludeFlag, legacyStatusOverrideNameFlag } from "./status.command.ts"; + +describe("legacy status --override-name flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple overrides", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR", "studio.url=BAZ"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR", "studio.url=BAZ"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual([]); + }); + + test("rejects malformed CSV (unterminated quote)", async () => { + const exit = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: { "override-name": ['"api.url=FOO'] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("legacy status --exclude flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple exclusions", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: { exclude: ["kong,auth"] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual(["kong", "auth"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual([]); + }); +}); From 003c83fc7568135352e6c4cc19b2046de86a302f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 13:25:49 +0100 Subject: [PATCH 07/81] fix(cli): reject short jwt secrets in status (review: PRRT_kwDOErm0O86N4wL1) Go's Config.Validate fails config-load when auth.jwt_secret is set but shorter than 16 characters (pkg/config/apikeys.go:45-47), before any command can render output. The TS resolver accepted any non-empty value and signed ANON_KEY/SERVICE_ROLE_KEY with it, letting `status -o env/json` succeed and print keys for a config the Go CLI and local stack both reject. --- .../legacy/commands/status/SIDE_EFFECTS.md | 5 +++- .../legacy/commands/status/status.errors.ts | 11 +++++++++ .../legacy/commands/status/status.handler.ts | 15 ++++++++++-- .../status/status.integration.test.ts | 19 +++++++++++++++ .../shared/legacy-local-config-values.ts | 24 ++++++++++++++++++- .../legacy-local-config-values.unit.test.ts | 15 +++++++++++- 6 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 4fecbad100..7a9164c7ac 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -43,6 +43,7 @@ resolved from local `config.toml` and the local Docker daemon. | `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | +| `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | ## Telemetry Events Fired @@ -167,7 +168,9 @@ Additive — no Go CLI equivalent. Emits the same resolved value map via - Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a Go-byte-exact HS256 signer (`legacy-go-jwt.ts`), not `@supabase/stack`'s `generateJwt` — the latter uses a different issuer, expiry, and claim order that would not match what Go prints - for local dev keys. + for local dev keys. A configured `auth.jwt_secret` shorter than 16 characters fails the command + (`LegacyStatusInvalidConfigError`), matching Go's `Config.Validate` rejecting it at config-load + time before any command can render output. - `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced identically in `legacy-local-config-values.ts`. diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts index 786e7fa8ff..33da3e92f6 100644 --- a/apps/cli/src/legacy/commands/status/status.errors.ts +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -33,3 +33,14 @@ export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusD export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ readonly message: string; }> {} + +/** + * `config.toml` resolved to a value `Config.Validate` would reject before status + * ever renders — e.g. an `auth.jwt_secret` shorter than 16 characters + * (`pkg/config/apikeys.go:45-47`). + */ +export class LegacyStatusInvalidConfigError extends Data.TaggedError( + "LegacyStatusInvalidConfigError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 21a4026add..4db441befc 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -31,6 +31,7 @@ import { LegacyStatusDbInspectError, LegacyStatusDbNotReadyError, LegacyStatusDbNotRunningError, + LegacyStatusInvalidConfigError, LegacyStatusListError, LegacyStatusOverrideParseError, } from "./status.errors.ts"; @@ -182,8 +183,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `names` is intentionally unused here: the pretty-mode branch below // recomputes with an empty override map (matching Go), and every other - // branch only needs `values`. - const { values } = legacyStatusValues(config, containerIds, hostname, excluded, overrides); + // branch only needs `values`. `legacyStatusValues` can throw + // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) — Go's + // `Config.Validate` rejects that at config-load time, before this command + // would ever render anything, so it's surfaced here as a hard failure + // rather than silently signing with the too-short secret. + const { values } = yield* Effect.try({ + try: () => legacyStatusValues(config, containerIds, hostname, excluded, overrides), + catch: (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over // --output-format; -o pretty/unset falls through to text/json/stream-json. diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 28fa703817..098cf66b0c 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -272,6 +272,25 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { + // Go's Config.Validate rejects this at config-load time + // (pkg/config/apikeys.go:45-47), before any command can render output. + const { layer, child } = setup({ + configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusInvalidConfigError"); + expect(JSON.stringify(exit.cause)).toContain( + "Invalid config for auth.jwt_secret. Must be at least 16 characters", + ); + } + expect(child.spawned.some((s) => s.args[0] === "ps")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.live("reports status using schema defaults when config.toml is missing entirely", () => { // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 2c6b4fac51..f730369d7f 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -63,9 +63,30 @@ function apiUrlWithPath(apiExternalUrl: string, path: string): string { return `${apiExternalUrl}${path}`; } +/** + * Thrown by {@link legacyResolveLocalConfigValues} when `auth.jwt_secret` is + * configured but too short to sign with, mirroring Go's `Config.Validate` + * (`pkg/config/apikeys.go:45-47`) — that check runs at config-load time, before + * any command renders output, so no local dev stack can even start with a + * short secret. + */ +export class LegacyInvalidJwtSecretError extends Error { + constructor() { + super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); + this.name = "LegacyInvalidJwtSecretError"; + } +} + +/** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ +const MIN_JWT_SECRET_LENGTH = 16; + /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ function resolveJwtSecret(configured: string | undefined): string { - return configured !== undefined && configured.length > 0 ? configured : defaultJwtSecret; + if (configured === undefined || configured.length === 0) return defaultJwtSecret; + if (configured.length < MIN_JWT_SECRET_LENGTH) { + throw new LegacyInvalidJwtSecretError(); + } + return configured; } function resolveOpaqueKey(configured: string | undefined, fallback: string): string { @@ -82,6 +103,7 @@ function resolveSignedKey( : legacyGenerateGoJwt(jwtSecret, role); } +/** @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. */ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 0abfb35a57..c23eca5d8b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -2,7 +2,10 @@ import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; +import { + LegacyInvalidJwtSecretError, + legacyResolveLocalConfigValues, +} from "./legacy-local-config-values.ts"; const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); @@ -98,6 +101,16 @@ describe("legacyResolveLocalConfigValues", () => { expect(values.anonKey).not.toBe(""); }); + it("rejects a configured jwt_secret shorter than 16 characters", () => { + // Go's Config.Validate fails this at config-load time, before any command + // can render output (pkg/config/apikeys.go:45-47) — reproduced as a thrown + // error here rather than silently signing with the too-short secret. + const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1")).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + it("hardcodes the Go-parity local S3 credentials", () => { const config = baseConfig(); const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); From d74f043d0e87b878f55e6e96f1e41f46fb695fdc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:00:34 +0100 Subject: [PATCH 08/81] fix(cli): honor SUPABASE_AUTH_* env overrides in status keys (review: PRRT_kwDOErm0O86N4wLx) Go's config loader binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() (pkg/config/config.go:529-535), so SUPABASE_AUTH_JWT_SECRET/PUBLISHABLE_KEY/ SECRET_KEY/ANON_KEY/SERVICE_ROLE_KEY override the corresponding config.toml value at higher precedence. legacyResolveLocalConfigValues only read the decoded config object, so a local stack started with those env overrides had `status` print keys that didn't match the running Auth service. Scoped to exactly the 5 auth fields this module reads, not a general @supabase/config port of Viper's AutomaticEnv. --- .../legacy/commands/status/SIDE_EFFECTS.md | 38 +++- .../shared/legacy-local-config-values.ts | 110 ++++++++++- .../legacy-local-config-values.unit.test.ts | 180 ++++++++++++++++-- 3 files changed, 298 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 7a9164c7ac..53b04e9144 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| ------------------------------------------------------ | ------ | -------------------------------------------------------- | +| `/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 | ## Files Written @@ -23,11 +24,20 @@ resolved from local `config.toml` and the local Docker daemon. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | -| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | -| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | +| Variable | Purpose | Required? | +| -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | +| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | +| `SUPABASE_AUTH_JWT_SECRET` | overrides `auth.jwt_secret` | no | +| `SUPABASE_AUTH_PUBLISHABLE_KEY` | overrides `auth.publishable_key` | no | +| `SUPABASE_AUTH_SECRET_KEY` | overrides `auth.secret_key` | no | +| `SUPABASE_AUTH_ANON_KEY` | overrides `auth.anon_key` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | overrides `auth.service_role_key` | no | + +The `SUPABASE_AUTH_*` vars mirror Go's Viper `AutomaticEnv` (`SetEnvPrefix("SUPABASE")` + +`.`→`_` key replacer, `pkg/config/config.go:529-535`) and take precedence over the corresponding +`config.toml` value, matching Viper's real precedence order. `docker` (or `podman` as a fallback) must be on `PATH`. @@ -44,6 +54,7 @@ resolved from local `config.toml` and the local Docker daemon. | `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | | `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `auth.signing_keys_path` is configured but the file is missing/malformed, or its first key's algorithm is not `RS256`/`ES256` | ## Telemetry Events Fired @@ -171,6 +182,17 @@ Additive — no Go CLI equivalent. Emits the same resolved value map via for local dev keys. A configured `auth.jwt_secret` shorter than 16 characters fails the command (`LegacyStatusInvalidConfigError`), matching Go's `Config.Validate` rejecting it at config-load time before any command can render output. +- When `auth.signing_keys_path` is set and resolves to a non-empty JWK array, `anon_key`/ + `service_role_key` are instead signed asymmetrically (RS256/ES256) with the file's first key, + matching Go's `generateJWT` (`pkg/config/apikeys.go:76-113`) — a relative path resolves against + `/supabase`. This path is skipped entirely when `auth.anon_key`/`auth.service_role_key` + are explicitly configured. A missing/malformed file, or a first key with an algorithm other than + `RS256`/`ES256`, fails the command (`LegacyStatusInvalidConfigError`). +- `SUPABASE_AUTH_JWT_SECRET`/`SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY`/ + `SUPABASE_AUTH_ANON_KEY`/`SUPABASE_AUTH_SERVICE_ROLE_KEY` override the corresponding + `config.toml` value at higher precedence, matching Go's Viper `AutomaticEnv` — an empty env var + is treated as unset. This is scoped to exactly the 5 auth fields `status` reads; it is not a + general `@supabase/config` port of Viper's `AutomaticEnv` (which applies to every config field). - `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced identically in `legacy-local-config-values.ts`. diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index f730369d7f..328755acea 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1,8 +1,16 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + import type { ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { Schema } from "effect"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; -import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; /** * Go-parity derived local-dev config values, ported from `utils.Config`'s @@ -80,6 +88,20 @@ export class LegacyInvalidJwtSecretError extends Error { /** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ const MIN_JWT_SECRET_LENGTH = 16; +/** + * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), + * so any config field can be overridden by a `SUPABASE_` env var — + * this resolves it for exactly the 5 auth fields this module reads, at the + * same higher-than-config.toml precedence Viper gives env vars. An empty env + * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never + * enabled in `config.go`). + */ +function envOverride(name: string, configured: string | undefined): string | undefined { + const value = process.env[name]; + return value !== undefined && value.length > 0 ? value : configured; +} + /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ function resolveJwtSecret(configured: string | undefined): string { if (configured === undefined || configured.length === 0) return defaultJwtSecret; @@ -96,20 +118,74 @@ function resolveOpaqueKey(configured: string | undefined, fallback: string): str function resolveSignedKey( configured: string | undefined, jwtSecret: string, + signingKey: LegacyJwk | undefined, role: "anon" | "service_role", ): string { - return configured !== undefined && configured.length > 0 - ? configured + if (configured !== undefined && configured.length > 0) return configured; + return signingKey !== undefined + ? legacyGenerateAsymmetricGoJwt(signingKey, role) : legacyGenerateGoJwt(jwtSecret, role); } -/** @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. */ +/** Matches Go's `JWK` struct fields (`pkg/config/auth.go:88-108`) — see `LegacyJwk`. */ +const LegacyJwkSchema = Schema.Struct({ + kty: Schema.String, + kid: Schema.optionalKey(Schema.String), + alg: Schema.optionalKey(Schema.String), + n: Schema.optionalKey(Schema.String), + e: Schema.optionalKey(Schema.String), + d: Schema.optionalKey(Schema.String), + p: Schema.optionalKey(Schema.String), + q: Schema.optionalKey(Schema.String), + dp: Schema.optionalKey(Schema.String), + dq: Schema.optionalKey(Schema.String), + qi: Schema.optionalKey(Schema.String), + crv: Schema.optionalKey(Schema.String), + x: Schema.optionalKey(Schema.String), + y: Schema.optionalKey(Schema.String), +}); +const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)); + +/** + * Go's `Config.Validate` (`pkg/config/config.go:877-878,1059-1062`): a relative + * `signing_keys_path` resolves against `/supabase`, then the file is + * read and JSON-decoded into `[]JWK`. Only the first key is ever used + * ({@link resolveSignedKey}), matching `generateJWT`'s `a.SigningKeys[0]`. + * + * Uses `node:fs` directly (not the `FileSystem` Effect service other Go-parity + * resolvers in `legacy/` use for file reads) so this function — and its large + * existing test surface — can stay a plain synchronous resolver; this is an + * optional, rarely-configured field, not worth threading Effect dependencies + * through `legacyStatusValues`/`status.handler.ts` for. + */ +function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { + const absolutePath = isAbsolute(signingKeysPath) + ? signingKeysPath + : join(workdir, "supabase", signingKeysPath); + const contents = readFileSync(absolutePath, "utf8"); + const jwks = decodeLegacyJwks(JSON.parse(contents)); + return jwks[0]; +} + +/** + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. + */ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, + workdir: string, ): LegacyLocalConfigValues { const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); - const jwtSecret = resolveJwtSecret(config.auth.jwt_secret); + const jwtSecret = resolveJwtSecret( + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret), + ); + const signingKeysPath = config.auth.signing_keys_path; + const signingKey = + signingKeysPath !== undefined && signingKeysPath.length > 0 + ? loadFirstSigningKey(workdir, signingKeysPath) + : undefined; return { apiUrl: apiExternalUrl, @@ -120,11 +196,27 @@ export function legacyResolveLocalConfigValues( studioUrl: `http://${hostname}:${config.studio.port}`, mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, - publishableKey: resolveOpaqueKey(config.auth.publishable_key, defaultPublishableKey), - secretKey: resolveOpaqueKey(config.auth.secret_key, defaultSecretKey), + publishableKey: resolveOpaqueKey( + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key), + defaultPublishableKey, + ), + secretKey: resolveOpaqueKey( + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key), + defaultSecretKey, + ), jwtSecret, - anonKey: resolveSignedKey(config.auth.anon_key, jwtSecret, "anon"), - serviceRoleKey: resolveSignedKey(config.auth.service_role_key, jwtSecret, "service_role"), + anonKey: resolveSignedKey( + envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key), + jwtSecret, + signingKey, + "anon", + ), + serviceRoleKey: resolveSignedKey( + envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key), + jwtSecret, + signingKey, + "service_role", + ), storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index c23eca5d8b..972e9bd595 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -1,22 +1,36 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { importJWK, jwtVerify } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { LegacyInvalidJwtSecretError, legacyResolveLocalConfigValues, } from "./legacy-local-config-values.ts"; const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-local-config-values-test"; function baseConfig(overrides: Record = {}): ProjectConfig { return decodeConfig({ project_id: "test", ...overrides }); } +/** RSA JWK matching Go's `JWK` struct field names (kty/n/e/d/p/q/dp/dq/qi). */ +function generateRsaJwk(): Record { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; +} + describe("legacyResolveLocalConfigValues", () => { it("derives every URL from api.external_url when unset", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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"); @@ -30,32 +44,32 @@ describe("legacyResolveLocalConfigValues", () => { it("uses https and the configured port when api.tls.enabled", () => { const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "::1"); + const values = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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"); @@ -65,14 +79,14 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we // only assert the resolver wires the default secret through to both roles. const [, anonPayload] = values.anonKey.split("."); @@ -89,14 +103,14 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.jwtSecret).toBe("a".repeat(32)); expect(values.anonKey).not.toBe(""); }); @@ -106,18 +120,158 @@ describe("legacyResolveLocalConfigValues", () => { // can render output (pkg/config/apikeys.go:45-47) — reproduced as a thrown // error here rather than silently signing with the too-short secret. const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1")).toThrow( + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( LegacyInvalidJwtSecretError, ); }); it("hardcodes the Go-parity local S3 credentials", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); expect(values.storageS3SecretAccessKey).toBe( "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", ); expect(values.storageS3Region).toBe("local"); }); + + describe("SUPABASE_AUTH_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // (pkg/config/config.go:529-535) — env vars take precedence over config.toml. + const ENV_KEYS = [ + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides jwt_secret even when config.toml sets one", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ + auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ + auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + }); + const values = legacyResolveLocalConfigValues(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"] = ""; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + }); + + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); + + function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); + } + + 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 = legacyResolveLocalConfigValues(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 { 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 = await jwtVerify(values.serviceRoleKey, publicKey); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }); + + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + 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 = legacyResolveLocalConfigValues(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("throws when the signing keys file does not exist", () => { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow(); + }); + + 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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "unsupported algorithm: RS512", + ); + }); + }); }); From 68410196ba78f20d2a682c6b13211f9e63b6db16 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:00:46 +0100 Subject: [PATCH 09/81] fix(cli): sign status keys with asymmetric signing keys when configured (review: PRRT_kwDOErm0O86N4wLk) Go's generateJWT signs anon/service_role with the first key in auth.signing_keys_path (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK array (pkg/config/apikeys.go:76-113). The TS resolver only ever HMAC-signed with jwt_secret. Ports GenerateAsymmetricJWT via legacyGenerateAsymmetricGoJwt (RFC 7517 JWK -> Node crypto private key, ieee-p1363 signature encoding for ES256's raw r||s format) and wires it into legacyResolveLocalConfigValues, which now needs workdir to resolve a relative signing_keys_path against /supabase. --- .../legacy/commands/status/status.handler.ts | 21 ++- .../legacy/commands/status/status.values.ts | 3 +- .../status/status.values.unit.test.ts | 177 ++++++++++++++++-- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 83 +++++++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 79 +++++++- 5 files changed, 336 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 4db441befc..f8666006f9 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -184,12 +184,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `names` is intentionally unused here: the pretty-mode branch below // recomputes with an empty override map (matching Go), and every other // branch only needs `values`. `legacyStatusValues` can throw - // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) — Go's - // `Config.Validate` rejects that at config-load time, before this command - // would ever render anything, so it's surfaced here as a hard failure - // rather than silently signing with the too-short secret. + // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) or a + // signing-keys-file read/parse error — Go's `Config.Validate` rejects both + // at config-load time, before this command would ever render anything, so + // they're surfaced here as a hard failure rather than silently falling + // back to a default/HMAC-signed key. const { values } = yield* Effect.try({ - try: () => legacyStatusValues(config, containerIds, hostname, excluded, overrides), + try: () => + legacyStatusValues(config, containerIds, hostname, excluded, overrides, cliConfig.workdir), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), @@ -234,7 +236,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. // Recompute with an empty override map so the rendered table matches Go // exactly instead of leaking `--override-name` into pretty-mode output. - const pretty = legacyStatusValues(config, containerIds, hostname, excluded, new Map()); + const pretty = legacyStatusValues( + config, + containerIds, + hostname, + excluded, + new Map(), + cliConfig.workdir, + ); yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 4b8c894fdc..0b6e301c58 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -222,8 +222,9 @@ export function legacyStatusValues( hostname: string, excluded: ReadonlyArray, overrides: ReadonlyMap, + workdir: string, ): LegacyStatusValuesResult { - const local = legacyResolveLocalConfigValues(config, hostname); + const local = legacyResolveLocalConfigValues(config, hostname, workdir); const names = resolveOutputNames(overrides); const isExcluded = (id: string) => excluded.includes(id); diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts index bee78682a1..dd277f56f3 100644 --- a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -28,6 +28,7 @@ const CONTAINER_IDS: LegacyStatusContainerIds = { const HOSTNAME = "127.0.0.1"; const NONE: ReadonlyArray = []; const NO_OVERRIDES = new Map(); +const WORKDIR = "/tmp/status-values-test"; describe("legacyStatusValues", () => { it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { @@ -39,7 +40,14 @@ describe("legacyStatusValues", () => { storage: { enabled: false }, edge_runtime: { enabled: false }, }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(Object.keys(values)).toEqual(["DB_URL"]); expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); }); @@ -52,13 +60,21 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeDefined(); }); it("omits API_URL when api.enabled is false", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.API_URL).toBeUndefined(); }); @@ -69,6 +85,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.kong], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeUndefined(); }); @@ -80,13 +97,21 @@ describe("legacyStatusValues", () => { HOSTNAME, ["kong"], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeUndefined(); }); it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.REST_URL).toBeUndefined(); expect(values.GRAPHQL_URL).toBeUndefined(); }); @@ -98,6 +123,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.rest], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -111,6 +137,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); @@ -123,6 +150,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["postgrest"], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -138,19 +166,34 @@ describe("legacyStatusValues", () => { 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 } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + 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 config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -161,6 +204,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.edgeRuntime], NO_OVERRIDES, + WORKDIR, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -174,6 +218,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["edge-runtime"], NO_OVERRIDES, + WORKDIR, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -187,13 +232,21 @@ describe("legacyStatusValues", () => { 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 } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.STUDIO_URL).toBeUndefined(); }); @@ -204,6 +257,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.studio], NO_OVERRIDES, + WORKDIR, ); expect(values.STUDIO_URL).toBeUndefined(); }); @@ -215,6 +269,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["studio"], NO_OVERRIDES, + WORKDIR, ); expect(values.STUDIO_URL).toBeUndefined(); }); @@ -226,19 +281,34 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.MCP_URL).toBeDefined(); }); it("omits MCP_URL when kong is disabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.MCP_URL).toBeUndefined(); }); it("omits MCP_URL when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.MCP_URL).toBeUndefined(); }); }); @@ -251,6 +321,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.PUBLISHABLE_KEY).toBeDefined(); expect(values.SECRET_KEY).toBeDefined(); @@ -261,7 +332,14 @@ describe("legacyStatusValues", () => { it("omits all 5 auth fields when auth.enabled is false", () => { const config = baseConfig({ auth: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); expect(values.SECRET_KEY).toBeUndefined(); expect(values.JWT_SECRET).toBeUndefined(); @@ -276,6 +354,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.auth], NO_OVERRIDES, + WORKDIR, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); @@ -287,6 +366,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["gotrue"], NO_OVERRIDES, + WORKDIR, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); @@ -300,6 +380,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.MAILPIT_URL).toBeDefined(); expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); @@ -307,7 +388,14 @@ describe("legacyStatusValues", () => { it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.MAILPIT_URL).toBeUndefined(); expect(values.INBUCKET_URL).toBeUndefined(); }); @@ -319,6 +407,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.inbucket], NO_OVERRIDES, + WORKDIR, ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -330,6 +419,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["mailpit"], NO_OVERRIDES, + WORKDIR, ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -343,6 +433,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeDefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); @@ -352,7 +443,14 @@ describe("legacyStatusValues", () => { it("omits storage S3 fields when storage.enabled is false", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -363,6 +461,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.storage], NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -376,13 +475,21 @@ describe("legacyStatusValues", () => { 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 } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); }); @@ -391,7 +498,14 @@ describe("legacyStatusValues", () => { 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 } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + 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"); }); @@ -401,7 +515,14 @@ describe("legacyStatusValues", () => { ["api.url", "CUSTOM_API_URL"], ["db.url", "CUSTOM_DB_URL"], ]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.CUSTOM_API_URL).toBeDefined(); expect(values.CUSTOM_DB_URL).toBeDefined(); expect(values.API_URL).toBeUndefined(); @@ -410,7 +531,14 @@ describe("legacyStatusValues", () => { it("leaves unrelated fields at their default name when only one is overridden", () => { const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.REST_URL).toBeDefined(); }); @@ -420,7 +548,14 @@ describe("legacyStatusValues", () => { ["auth.anon_key", "CUSTOM_ANON_KEY"], ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], ]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.CUSTOM_JWT_SECRET).toBeDefined(); expect(values.CUSTOM_ANON_KEY).toBeDefined(); expect(values.CUSTOM_SERVICE_ROLE_KEY).toBeDefined(); @@ -431,7 +566,14 @@ describe("legacyStatusValues", () => { it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); expect(values.MAILPIT_URL).toBeDefined(); expect(values.INBUCKET_URL).toBeUndefined(); @@ -449,6 +591,7 @@ describe("legacyStatusValues", () => { HOSTNAME, excluded, NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.STUDIO_URL).toBeUndefined(); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index 7c6c75d178..b07701c2bd 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -1,8 +1,35 @@ -import { createHmac } from "node:crypto"; +import { createHmac, createPrivateKey, createSign } from "node:crypto"; + +/** + * RFC 7517 JWK fields Go's `JWK` struct round-trips (`pkg/config/auth.go:88-108`, + * `toml`/`json` tags `kty`, `kid`, `alg`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, + * `qi`, `crv`, `x`, `y`) — field names match exactly, so a signing-keys file can + * be parsed straight into this shape. A superset of Node's own + * `crypto.webcrypto.JsonWebKey` (which omits `kid`), so it's still assignable + * wherever that type is expected (e.g. `createPrivateKey`'s `format: "jwk"` input). + */ +export interface LegacyJwk { + readonly kty: string; + readonly kid?: string; + readonly alg?: string; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} /** * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` * keys, ported from `CustomClaims`/`generateJWT` (`apps/cli-go/pkg/config/apikeys.go:23-40,75-86`). + * {@link legacyGenerateAsymmetricGoJwt} below covers the RS256/ES256 branch of + * the same Go function, taken when `auth.signing_keys_path` is configured. * * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, @@ -36,3 +63,57 @@ export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role const signature = createHmac("sha256", secret).update(data).digest("base64url"); return `${data}.${signature}`; } + +/** Go's asymmetric-JWT expiry: `time.Now().Add(time.Hour * 24 * 365 * 10)` (10 years). */ +const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; + +/** + * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from + * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK + * array (`pkg/config/apikeys.go:76-80`) — the first key in the file signs both + * the anon and service_role tokens. Same claim shape as {@link legacyGenerateGoJwt} + * (`iss`/`role`/`exp`), except the expiry is 10 years from now rather than Go's + * fixed HMAC-path timestamp, since `generateJWT` sets `claims.ExpiresAt` + * explicitly before calling this function instead of falling through to + * `CustomClaims.NewToken()`'s fixed default. + * + * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` + * (RSA/EC key types) + this function's own switch on `jwk.alg`. The header key + * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically + * sorting `map[string]interface{}` keys — `kid` is only present when set on + * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. + * + * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA + * signature output is DER-encoded, which is not the raw (r‖s) format JWS + * requires — verified by round-tripping through `jose`'s `jwtVerify`. + */ +export function legacyGenerateAsymmetricGoJwt( + jwk: LegacyJwk, + role: "anon" | "service_role", +): string { + const algorithm = jwk.alg; + if (algorithm !== "RS256" && algorithm !== "ES256") { + throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); + } + const header = + jwk.kid !== undefined && jwk.kid.length > 0 + ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } + : { alg: algorithm, typ: "JWT" }; + const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + const headerEncoded = base64UrlEncode(JSON.stringify(header)); + const payloadEncoded = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt }), + ); + const data = `${headerEncoded}.${payloadEncoded}`; + + const privateKey = createPrivateKey({ key: jwk, format: "jwk" }); + const signature = + algorithm === "RS256" + ? createSign("RSA-SHA256").update(data).end().sign(privateKey) + : createSign("sha256") + .update(data) + .end() + .sign({ key: privateKey, dsaEncoding: "ieee-p1363" }); + + return `${data}.${signature.toString("base64url")}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index 3c8aa39b84..c44a215d30 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -1,10 +1,32 @@ -import { createHmac } from "node:crypto"; +import { createHmac, generateKeyPairSync } from "node:crypto"; +import { importJWK, jwtVerify } from "jose"; import { describe, expect, it } from "vitest"; -import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; +function generateRsaJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "RSA", alg: "RS256", kid }; +} + +function generateEcJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "EC", alg: "ES256", kid }; +} + +function publicJwkOf(jwk: LegacyJwk): LegacyJwk { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicJwk } = jwk; + return publicJwk; +} + function decodeSegment(segment: string): string { return Buffer.from(segment, "base64url").toString("utf8"); } @@ -63,3 +85,56 @@ describe("legacyGenerateGoJwt", () => { expect(a).not.toBe(b); }); }); + +describe("legacyGenerateAsymmetricGoJwt", () => { + it("signs and verifies an RS256 token from an RSA JWK", async () => { + const jwk = generateRsaJwk("rsa-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + + it("signs and verifies an ES256 token from an EC JWK", async () => { + const jwk = generateEcJwk("ec-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role"); + const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); + expect(protectedHeader).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }); + + it("omits the kid header entirely when the JWK has no kid", () => { + const jwk = generateRsaJwk(); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [header] = token.split("."); + const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); + expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); + }); + + it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { + const jwk = generateRsaJwk(); + const before = Math.floor(Date.now() / 1000); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [, payload] = token.split("."); + const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); + const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; + expect(decoded.exp).toBeGreaterThanOrEqual(before + tenYearsSeconds); + expect(decoded.exp).toBeLessThan(before + tenYearsSeconds + 10); + }); + + it("rejects an unsupported algorithm", () => { + const jwk = { ...generateRsaJwk(), alg: "RS512" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "unsupported algorithm: RS512", + ); + }); + + it("rejects a JWK with no algorithm", () => { + const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow( + "unsupported algorithm: ", + ); + }); +}); From 9a382553ff5b135af779739d4245e0f2390e7d2c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:59:36 +0100 Subject: [PATCH 10/81] fix(cli): validate JWK kty/curve before asymmetric signing Go's jwkToRSAPrivateKey/jwkToECDSAPrivateKey reject a JWK whose kty doesn't match its claimed alg, and an EC key whose curve isn't P-256 (pkg/config/auth.go). legacyGenerateAsymmetricGoJwt only checked alg, so an EC key forged with alg: RS256 (or a non-P-256 curve claimed as ES256) signed "successfully" and produced a spec-invalid token that silently fails verification instead of raising an error. Found independently by the security and DX reviewers in review-changes. --- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 20 +++++++++++++++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index b07701c2bd..6a4ddc5a47 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -78,7 +78,14 @@ const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; * `CustomClaims.NewToken()`'s fixed default. * * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` - * (RSA/EC key types) + this function's own switch on `jwk.alg`. The header key + * (RSA/EC key types) + this function's own switch on `jwk.alg`. `kty`/`alg` + * are cross-validated (RS256 requires `kty: "RSA"`, ES256 requires + * `kty: "EC"` and `crv: "P-256"`) — matching Go's `jwkToRSAPrivateKey` / + * `jwkToECDSAPrivateKey`, which reject any other combination rather than + * signing with a mismatched key or curve (Node's `createPrivateKey`/`createSign` + * do not themselves catch this: an EC key signed as RS256, or a non-P-256 + * curve signed as ES256, both "succeed" and produce a spec-invalid token that + * silently fails verification instead of raising an error). The header key * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically * sorting `map[string]interface{}` keys — `kid` is only present when set on * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. @@ -95,6 +102,17 @@ export function legacyGenerateAsymmetricGoJwt( if (algorithm !== "RS256" && algorithm !== "ES256") { throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); } + if (algorithm === "RS256" && jwk.kty !== "RSA") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (algorithm === "ES256") { + if (jwk.kty !== "EC") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (jwk.crv !== "P-256") { + throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); + } + } const header = jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index c44a215d30..095ec1480f 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -137,4 +137,28 @@ describe("legacyGenerateAsymmetricGoJwt", () => { "unsupported algorithm: ", ); }); + + it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { + const jwk = { ...generateEcJwk(), alg: "RS256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: EC"); + }); + + it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { + const jwk = { ...generateRsaJwk(), alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: RSA"); + }); + + it("rejects an ES256 EC key whose curve is not P-256", () => { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); + const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported curve: P-384"); + }); + + it("rejects an ES256 EC key with no curve at all", () => { + const jwk = generateEcJwk(); + const { crv: _crv, ...jwkWithoutCurve } = jwk; + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + "unsupported curve: ", + ); + }); }); From e74201b5c88ed10c5e0ad22fe81585b13e95f4a8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:59:44 +0100 Subject: [PATCH 11/81] refactor(cli): resolve status state once instead of calling legacyStatusValues twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status.handler.ts called legacyStatusValues twice per invocation (real values, then pretty-mode values with an empty override map) — only the first call was wrapped in Effect.try. With signing_keys_path support this doubled file I/O + JWT signing on every text-mode run, and left the second call able to throw an uncaught exception if it ever diverged from the first. Splits legacyStatusValues into legacyResolveStatusState (the throwing half: local config resolution + gating) and legacyStatusValuesFromState (pure name remapping), so the handler resolves state once, guards it once, and reuses it for both value maps. --- .../legacy/commands/status/status.handler.ts | 38 ++++---- .../legacy/commands/status/status.values.ts | 90 ++++++++++++++++--- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index f8666006f9..6c4921d8bd 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -38,8 +38,9 @@ import { import { legacyRenderStatusPretty } from "./status.pretty.ts"; import { LEGACY_STATUS_FIELDS, + legacyResolveStatusState, legacyStatusContainerIds, - legacyStatusValues, + legacyStatusValuesFromState, } from "./status.values.ts"; /** @@ -181,22 +182,23 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // 7. --override-name KEY=VALUE parsing. const overrides = yield* parseOverrides(flags.overrideName); - // `names` is intentionally unused here: the pretty-mode branch below - // recomputes with an empty override map (matching Go), and every other - // branch only needs `values`. `legacyStatusValues` can throw - // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) or a - // signing-keys-file read/parse error — Go's `Config.Validate` rejects both - // at config-load time, before this command would ever render anything, so - // they're surfaced here as a hard failure rather than silently falling - // back to a default/HMAC-signed key. - const { values } = yield* Effect.try({ + // `legacyResolveStatusState` can throw `LegacyInvalidJwtSecretError` (a short + // `auth.jwt_secret`) or a signing-keys-file read/parse error — Go's + // `Config.Validate` rejects both at config-load time, before this command + // would ever render anything, so they're surfaced here as a hard failure + // rather than silently falling back to a default/HMAC-signed key. Resolved + // once and reused for both the real and pretty-mode (empty-override) value + // maps below, so a configured `signing_keys_path` is read and the anon/ + // service_role JWTs signed only once per invocation, not twice. + const state = yield* Effect.try({ try: () => - legacyStatusValues(config, containerIds, hostname, excluded, overrides, cliConfig.workdir), + legacyResolveStatusState(config, containerIds, hostname, excluded, cliConfig.workdir), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), }), }); + const { values } = legacyStatusValuesFromState(state, overrides); // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over // --output-format; -o pretty/unset falls through to text/json/stream-json. @@ -234,16 +236,10 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the // CLI-supplied, override-populated `names` — `--override-name` only ever // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. - // Recompute with an empty override map so the rendered table matches Go - // exactly instead of leaking `--override-name` into pretty-mode output. - const pretty = legacyStatusValues( - config, - containerIds, - hostname, - excluded, - new Map(), - cliConfig.workdir, - ); + // Remap names from the already-resolved `state` (empty override map) so the + // rendered table matches Go exactly without leaking `--override-name` into + // pretty-mode output, and without a second (throwing) state resolution. + const pretty = legacyStatusValuesFromState(state, new Map()); yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 0b6e301c58..f8165f4790 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -209,23 +209,48 @@ export interface LegacyStatusValuesResult { } /** - * Port of Go's `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`). - * `excluded` matches each gated service by its container id (`legacyStatusContainerIds`) - * OR its default Docker image short name (`shortContainerImageName` above) — the 6 - * relevant Go config fields (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, - * `Inbucket.Image`, `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're - * never user-overridable and the default image is always the one to check. + * Everything `toValues()` needs that does NOT depend on `--override-name` — + * i.e. every field except the output KEY remapping. Resolving this once and + * reusing it for both the env/json/toml/yaml values (real overrides) and the + * pretty-table values (Go always recomputes with an empty override map, + * `status.go:236-243`) avoids re-reading `auth.signing_keys_path` and + * re-signing the anon/service_role JWTs a second time per invocation. */ -export function legacyStatusValues( +export interface LegacyStatusState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly kongEnabled: boolean; + readonly postgrestEnabled: boolean; + readonly studioEnabled: boolean; + readonly authEnabled: boolean; + readonly inbucketEnabled: boolean; + readonly storageEnabled: boolean; + readonly functionsEnabled: boolean; +} + +/** + * Port of the non-override-dependent half of Go's `(*CustomName).toValues(exclude...)` + * (`internal/status/status.go:50-97`): resolves local config values (URLs, keys — + * can throw, see {@link legacyResolveLocalConfigValues}) and the per-service gating + * booleans. `excluded` matches each gated service by its container id + * (`legacyStatusContainerIds`) OR its default Docker image short name + * (`legacyShortContainerImageName` above) — the 6 relevant Go config fields + * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, + * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never + * user-overridable and the default image is always the one to check. + * + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. + */ +export function legacyResolveStatusState( config: ProjectConfig, containerIds: LegacyStatusContainerIds, hostname: string, excluded: ReadonlyArray, - overrides: ReadonlyMap, workdir: string, -): LegacyStatusValuesResult { +): LegacyStatusState { const local = legacyResolveLocalConfigValues(config, hostname, workdir); - const names = resolveOutputNames(overrides); const isExcluded = (id: string) => excluded.includes(id); const kongEnabled = @@ -247,6 +272,32 @@ export function legacyStatusValues( !isExcluded(containerIds.edgeRuntime) && !isExcluded(EDGE_RUNTIME_IMAGE_NAME); + return { + config, + local, + kongEnabled, + postgrestEnabled, + studioEnabled, + authEnabled, + inbucketEnabled, + storageEnabled, + functionsEnabled, + }; +} + +/** + * Applies `--override-name` remapping to an already-resolved {@link LegacyStatusState}. + * Pure and non-throwing — every failure mode of `toValues()` lives in + * {@link legacyResolveStatusState}, which runs once per `status` invocation. + */ +export function legacyStatusValuesFromState( + state: LegacyStatusState, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const { config, local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; + const { inbucketEnabled, storageEnabled, functionsEnabled } = state; + const names = resolveOutputNames(overrides); + // Go always sets db.url unconditionally, before any gating (status.go:52). const values: Record = { [names.dbUrl]: local.dbUrl, @@ -289,3 +340,22 @@ export function legacyStatusValues( return { values, names, local }; } + +/** + * Convenience wrapper combining {@link legacyResolveStatusState} + + * {@link legacyStatusValuesFromState} in one call — used directly by tests that + * only need a single override map. `status.handler.ts` calls the two halves + * separately so it can resolve state once and reuse it for both the real and + * pretty-mode (empty-override) value maps without recomputing `local`. + */ +export function legacyStatusValues( + config: ProjectConfig, + containerIds: LegacyStatusContainerIds, + hostname: string, + excluded: ReadonlyArray, + overrides: ReadonlyMap, + workdir: string, +): LegacyStatusValuesResult { + const state = legacyResolveStatusState(config, containerIds, hostname, excluded, workdir); + return legacyStatusValuesFromState(state, overrides); +} From 60877ba497c91cbe920ea6db494c3cea3638ccda Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:59:50 +0100 Subject: [PATCH 12/81] fix(cli): match Go's signing-keys error wording and add missing integration coverage Go's Config.Validate fails a bad auth.signing_keys_path with "failed to read signing keys: %w" (open failure) or "failed to decode signing keys: %w" (parse failure) (pkg/config/config.go:1059-1062). The TS port let readFileSync/JSON.parse's raw Node error text through unwrapped instead. Also adds status.integration.test.ts coverage for the SUPABASE_AUTH_* env override and asymmetric-signing-key behaviors, which previously only had unit-level coverage on the pure resolver. --- .../status/status.integration.test.ts | 42 +++++++++++++++++++ .../shared/legacy-local-config-values.ts | 25 ++++++++++- .../legacy-local-config-values.unit.test.ts | 16 ++++++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 098cf66b0c..fedccad54b 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; @@ -5,6 +6,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach } from "vitest"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -19,6 +21,10 @@ import { legacyStatus } from "./status.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); +afterEach(() => { + delete process.env["SUPABASE_AUTH_JWT_SECRET"]; +}); + function flags(overrides: Partial = {}): LegacyStatusFlags { return { overrideName: [], @@ -291,6 +297,42 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { + // Go's Viper AutomaticEnv gives env vars higher precedence than config.toml + // (pkg/config/config.go:529-535) — a stack started with this env var set + // must report the env-derived secret, not the one in config.toml. + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"b".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + + it.live("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { + // Go's generateJWT signs with the first key in auth.signing_keys_path + // (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK + // array (pkg/config/apikeys.go:76-113). + const { layer, out, workdir } = setup({ + goOutput: Option.some("json"), + configContents: 'project_id = "demo"\n[auth]\nsigning_keys_path = "signing_keys.json"\n', + }); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "test-kid" }; + writeFileSync(join(workdir, "supabase", "signing_keys.json"), JSON.stringify([jwk])); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + const [headerSegment] = parsed.ANON_KEY?.split(".") ?? []; + const header = JSON.parse(Buffer.from(headerSegment ?? "", "base64url").toString()); + expect(header).toEqual({ alg: "RS256", kid: "test-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }); + it.live("reports status using schema defaults when config.toml is missing entirely", () => { // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 328755acea..efe8bda00b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -157,13 +157,34 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * existing test surface — can stay a plain synchronous resolver; this is an * optional, rarely-configured field, not worth threading Effect dependencies * through `legacyStatusValues`/`status.handler.ts` for. + * + * Error wording matches Go's two `Validate` failure branches exactly + * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode + * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ + * `JSON.parse`'s raw Node error text through unwrapped. */ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { const absolutePath = isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); - const contents = readFileSync(absolutePath, "utf8"); - const jwks = decodeLegacyJwks(JSON.parse(contents)); + + let contents: string; + try { + contents = readFileSync(absolutePath, "utf8"); + } catch (cause) { + throw new Error( + `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + let jwks: ReadonlyArray; + try { + jwks = decodeLegacyJwks(JSON.parse(contents)); + } catch (cause) { + throw new Error( + `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } return jwks[0]; } diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 972e9bd595..36c6d71f5f 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -261,9 +261,21 @@ describe("legacyResolveLocalConfigValues", () => { }); }); - it("throws when the signing keys file does not exist", () => { + it("throws a Go-worded error when the signing keys file does not exist", () => { const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", + ); + }); + + 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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to decode signing keys: ", + ); }); it("throws when the first key uses an unsupported algorithm", () => { From 7574db6ae7c0943ecc2d5edba170d0bef905965e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 15:49:24 +0100 Subject: [PATCH 13/81] fix(cli): preserve real Docker error text in status/stop container inspection (ci: e2e shard 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's assertContainerHealthy never special-cases a missing container — it wraps whatever ContainerInspect returns, so the real daemon error text ("No such container: ...") flows through. The TS port collapsed that case into a hardcoded "no such container" string via an "absent" sentinel, discarding the real text. Separately, legacyInspectContainerState's Effect.all([exitCode, stdout, stderr]) ran sequentially by default, awaiting exitCode (Node's "exit" event) before ever subscribing to the stdout/stderr streams. Node's "exit" can fire before a fast process's stdio pipes are drained, so the real Docker CLI's stderr was silently lost in the real subprocess environment even after removing the hardcoded string — reproduced against a real docker CLI subprocess, confirmed via runParity's stderr comparison in apps/cli-e2e. Same fix applied to the two other call sites in this file that share the pattern (legacyListContainersByLabel, legacyListVolumesByLabel). --- .../legacy/commands/status/status.handler.ts | 9 +-- .../status/status.integration.test.ts | 13 +++- .../legacy/shared/legacy-docker-lifecycle.ts | 64 +++++++++++-------- .../legacy-docker-lifecycle.unit.test.ts | 32 ++++++---- 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 6c4921d8bd..06d1030496 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -130,17 +130,12 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // container fails `ContainerInspect` itself, which surfaces as the generic // inspect error (status.go:147-150), not the "not running" branch (which // only applies to a present-but-stopped container, status.go:150-151). + // `legacyInspectContainerState` mirrors that: a missing container is just + // another non-zero exit, mapped below with the real Docker stderr text. if (!flags.ignoreHealthCheck) { const state = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), ); - if (state === "absent") { - return yield* Effect.fail( - new LegacyStatusDbInspectError({ - message: "failed to inspect container health: no such container", - }), - ); - } if (!state.running) { return yield* Effect.fail( new LegacyStatusDbNotRunningError({ diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index fedccad54b..8f5174a557 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -418,18 +418,25 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when the db container is absent", () => { + it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it wraps + // whatever `ContainerInspect` returns (`status.go:148-149`), so the real + // Docker stderr must flow through rather than a hardcoded TS string. const { layer } = setup({ route: defaultRoute({ dbInspectExitCode: 1, - dbInspectStderr: ["Error: No such container: x"], + dbInspectStderr: ["Error response from daemon: No such container: x"], }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbInspectError"); + expect(serialized).toContain( + "failed to inspect container health: Error response from daemon: No such container: x", + ); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index 2a89464e10..985410723f 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -40,10 +40,6 @@ function splitNonEmptyLines(text: string): ReadonlyArray { .filter((line) => line.length > 0); } -function isMissingContainerError(stderr: string): boolean { - return stderr.toLowerCase().includes("no such container"); -} - /** * Go's `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` * (`docker.go:99-104`, `status.go:126-131`) via `docker ps --filter @@ -81,11 +77,19 @@ export const legacyListContainersByLabel = ( }), ), ); - const [exitCode, stdout, stderr] = yield* Effect.all([ - child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stdout), - collectByteStream(child.stderr), - ]).pipe( + // Concurrency is required, not cosmetic: sequential `Effect.all` would + // await `exitCode` (resolved by Node's "exit" event) before subscribing + // to `stdout`/`stderr` at all. Node's "exit" can fire before a fast + // process's stdio pipes are drained, so a late subscriber sees an + // already-ended, empty stream instead of the buffered bytes. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( Effect.mapError( () => new LegacyDockerLifecycleListError({ message: "failed to list containers" }), ), @@ -108,10 +112,11 @@ export const legacyListContainersByLabel = ( /** * Go's `Docker.ContainerInspect(ctx, containerId)` (`docker.go:148`, * `status.go:148-155`) via `docker container inspect --format - * {{json .State}}`. A "no such container" stderr resolves to the literal - * `"absent"`, mirroring `errdefs.IsNotFound(err)` — every other non-zero exit - * propagates as `LegacyDockerLifecycleInspectError`, matching Go's - * `assertContainerHealthy`, which does not special-case any other inspect failure. + * {{json .State}}`. Go's `assertContainerHealthy` does not special-case a + * missing container — it wraps whatever error `ContainerInspect` returns + * (`status.go:148-149`), so every non-zero exit, including "no such + * container", propagates as `LegacyDockerLifecycleInspectError` carrying the + * real Docker stderr text. */ export const legacyInspectContainerState = (spawner: Spawner, containerId: string) => Effect.scoped( @@ -132,11 +137,16 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin }), ), ); - const [exitCode, stdout, stderr] = yield* Effect.all([ - child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stdout), - collectByteStream(child.stderr), - ]).pipe( + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( Effect.mapError( () => new LegacyDockerLifecycleInspectError({ @@ -146,9 +156,6 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin ); if (exitCode !== 0) { const message = stderr.trim(); - if (isMissingContainerError(message)) { - return "absent" as const; - } return yield* Effect.fail( new LegacyDockerLifecycleInspectError({ message: @@ -217,11 +224,16 @@ export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: stri }), ), ); - const [exitCode, stdout, stderr] = yield* Effect.all([ - child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stdout), - collectByteStream(child.stderr), - ]).pipe( + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( Effect.mapError( () => new LegacyDockerLifecycleListError({ message: "failed to list volumes" }), ), diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts index 29704048c4..39505f964d 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -200,17 +200,27 @@ describe("legacyInspectContainerState", () => { ); }); - it.live('resolves to "absent" when the container does not exist', () => { - const mock = mockSpawner({ - exitCode: 1, - stderr: "Error: No such container: supabase_db_my-app\n", - }); - return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( - Effect.map((state) => { - expect(state).toBe("absent"); - }), - ); - }); + it.live( + "fails with LegacyDockerLifecycleInspectError, preserving the real stderr, when the container does not exist", + () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it + // wraps whatever `ContainerInspect` returns (`status.go:148-149`), so a + // missing container is just another non-zero exit here too. + const mock = mockSpawner({ + exitCode: 1, + stderr: "Error response from daemon: No such container: supabase_db_my-app\n", + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", + ); + }), + ); + }, + ); it.live("fails with LegacyDockerLifecycleInspectError on any other inspect failure", () => { const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); From 1cc8deba699effe6caa6cb3c653bc6357f413eaa Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 15:49:32 +0100 Subject: [PATCH 14/81] fix(cli): print stop's "Stopping containers..." unconditionally (ci: e2e shard 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go prints this line via an unconditional, immediate fmt.Fprintln before any Docker call runs (docker.go:97), routed straight to stdout in non-interactive mode (tea.go's fakeProgram). The TS port gated it behind the shared output.task spinner's 200ms debounce, so the line was silently dropped whenever the underlying Docker calls resolved faster than that threshold — exactly what happens against the mocked/replayed Docker CLI in the e2e harness. Printing it directly via output.raw removes the race entirely. --- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 3 +- .../src/legacy/commands/stop/stop.handler.ts | 25 +++++------ .../commands/stop/stop.integration.test.ts | 45 ++++++++++--------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 4c918076e5..2711306a5e 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -70,7 +70,8 @@ This is a harmless, documented divergence: Go would reject an unknown `-o` flag ### `--output-format text` (Go CLI compatible) -- stderr (transient): `Stopping containers...` +- stdout: `Stopping containers...` (printed unconditionally before any Docker call, + matching Go's `fmt.Fprintln` — see `docker.go:97`) - stdout: `Stopped supabase local development setup.` (`supabase` rendered in Aqua/cyan when the output stream is a TTY, plain otherwise) - stderr (conditional): when any Docker volume still carries the project's diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 0d2ef56339..a660d8fe8f 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -108,16 +108,17 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF const deleteVolumes = flags.noBackup; const filterValue = legacyCliProjectFilterValue(searchProjectIdFilter); - // Captured (not discarded) so it can be `.fail()`ed or `.clear()`ed below, - // matching the project's established `output.task` usage pattern - // (apps/cli/CLAUDE.md's "always wrap API calls in output.task"). In - // non-interactive/CI runs the spinner never renders, but `.fail()`/`.clear()` - // still resolve cleanly — a discarded handle would otherwise leave a spinner - // that's started but never stopped. A single `Effect.tapError` around the - // whole list/stop/prune sequence (rather than one per step) fails the same - // task on any error without repeating the same branch at every call site. - const stopping = - output.format === "text" ? yield* output.task("Stopping containers...") : undefined; + // Go prints this line unconditionally and immediately — `docker.go:97`'s + // `fmt.Fprintln(w, "Stopping containers...")`, where `w` is a + // `StatusWriter` that `fmt.Println`s straight to stdout in non-interactive + // mode (`tea.go:59-60,87-90`) before any Docker call runs. The debounced + // `output.task` spinner used elsewhere in this codebase gates its message + // behind a delay, which drops this line whenever the underlying calls + // resolve faster than that threshold — exactly what happens against the + // mocked/replayed Docker CLI. Print it directly so it always appears. + if (output.format === "text") { + yield* output.raw("Stopping containers...\n"); + } yield* Effect.gen(function* () { const containerIds = yield* legacyListContainersByLabel(spawner, { @@ -212,9 +213,7 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF new LegacyStopNetworkPruneError({ message: "failed to prune networks" }), ); } - }).pipe(Effect.tapError(() => stopping?.fail() ?? Effect.void)); - - yield* stopping?.clear() ?? Effect.void; + }); if (output.format === "text") { // Written to stdout (no stream arg): `legacyAqua` must target stdout's own diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index edbd1031d2..4c3ddd3264 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -211,6 +211,7 @@ describe("legacy stop integration", () => { ["stop", "c1"], ["stop", "c2"], ]); + expect(out.stdoutText).toContain("Stopping containers..."); expect(out.stdoutText).toContain("Stopped"); expect(out.stdoutText).toContain("local development setup."); expect(out.stderrText).toContain( @@ -471,26 +472,30 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails cleanly in json mode without a text-mode spinner to dismiss", () => { - // No `output.task` handle exists outside text mode — this exercises that - // the failure path's `stopping?.fail() ?? Effect.void` no-ops correctly. - const { layer } = setup({ - format: "json", - configuredProjectId: "demo", - route: (args) => { - if (args[0] === "ps") return { stdout: ["c1"] }; - if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; - return { exitCode: 0 }; - }, - }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(legacyStop(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); - } - }).pipe(Effect.provide(layer)); - }); + it.live( + "fails the same way in json mode, where 'Stopping containers...' is never printed", + () => { + // The `output.format === "text"` gate around the "Stopping containers..." + // line means json mode skips it entirely; this exercises that the + // list/stop/prune failure path is unaffected by that gate. + const { layer } = setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }, + ); it.live("fails when container prune errors", () => { const { layer } = setup({ From ad5c212b8a6ad81b57d25707e2dd2ae8a1a49726 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 15:49:40 +0100 Subject: [PATCH 15/81] test(cli-test-helpers): ignore Docker client negotiation noise in parity request logs (ci: e2e shard 1/3) stop/status parity comparisons were failing on request-log mismatches that reflect nothing about CLI behavior: the real docker CLI issues a fresh HEAD /_ping handshake before every subprocess invocation (Go's SDK pings once per command via a persistent client), and negotiates its own API version segment into the URL path (/v1.51/ vs /v1.53/) based on the installed docker CLI/daemon, not anything the command controls. Strip both before comparing so parity reflects the actual Docker operations performed rather than client plumbing, mirroring the equivalent normalization already used for fixture matching in apps/cli-e2e/src/server/placeholder.ts. --- packages/cli-test-helpers/src/parity.ts | 32 ++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/cli-test-helpers/src/parity.ts b/packages/cli-test-helpers/src/parity.ts index 9b0aae97c1..a52401f85f 100644 --- a/packages/cli-test-helpers/src/parity.ts +++ b/packages/cli-test-helpers/src/parity.ts @@ -214,6 +214,24 @@ function snapshotChangedFiles(dir: string): FileRecord[] { // RunResult collection // --------------------------------------------------------------------------- +/** + * Docker Engine API calls carry two client-negotiation artifacts that reflect + * nothing about CLI behavior: an `HEAD /_ping` handshake the real `docker` CLI + * issues before every subprocess invocation (Go's SDK pings once per command + * via a persistent client; ts-legacy shells out to `docker` per operation, so + * it pings once per operation), and a negotiated API version segment in the + * URL path (`/v1.51/...` vs `/v1.53/...`) that reflects the installed docker + * CLI/daemon version, not anything the command controls. Strip both so parity + * comparisons reflect actual Docker operations (list, stop, prune, inspect, + * with their filters) rather than client plumbing. Mirrors the equivalent + * normalization in `apps/cli-e2e/src/server/placeholder.ts`'s + * `normalizeUrlPath`, applied here to the request-log comparison instead of + * fixture matching. + */ +function normalizeDockerRequestPath(pathname: string): string { + return pathname.replace(/\/v1\.\d+(\/|$)/g, "/__DOCKER_VERSION__$1"); +} + async function fetchRequestLog(apiUrl: string): Promise { const res = await fetch(`${apiUrl}/_ctrl/requests`); const raw = (await res.json()) as Array<{ @@ -222,12 +240,14 @@ async function fetchRequestLog(apiUrl: string): Promise { query: Record; body: unknown; }>; - return raw.map(({ method, pathname, query, body }) => ({ - method, - pathname, - query, - body, - })); + return raw + .filter(({ method, pathname }) => !(method === "HEAD" && pathname === "/_ping")) + .map(({ method, pathname, query, body }) => ({ + method, + pathname: normalizeDockerRequestPath(pathname), + query, + body, + })); } async function collectRunResult( From bd2fee925700865584a4bdf8ae7018bd0b3bb2c8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 16:08:51 +0100 Subject: [PATCH 16/81] fix(cli): apply SUPABASE_AUTH_SIGNING_KEYS_PATH env override to status (review: PRRT_kwDOErm0O86N7ctR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Viper binds SetEnvPrefix("SUPABASE") + AutomaticEnv() over every config field via UnmarshalExact's struct walk (pkg/config/config.go:531-535, 698-705), including the plain-string Auth.SigningKeysPath field (pkg/config/auth.go:164) — not just the 5 auth fields this module already wrapped with envOverride. Load() resolves that override before Validate() opens and parses the JWK file (config.go:735-745, 1059-1062), so an env-only SUPABASE_AUTH_SIGNING_KEYS_PATH was silently ignored and status fell back to HMAC-signed keys the running Auth service would reject. --- .../shared/legacy-local-config-values.ts | 7 ++- .../legacy-local-config-values.unit.test.ts | 43 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index efe8bda00b..1d8c9ed2b9 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -92,7 +92,7 @@ const MIN_JWT_SECRET_LENGTH = 16; * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), * so any config field can be overridden by a `SUPABASE_` env var — - * this resolves it for exactly the 5 auth fields this module reads, at the + * this resolves it for exactly the 6 auth fields this module reads, at the * same higher-than-config.toml precedence Viper gives env vars. An empty env * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never * enabled in `config.go`). @@ -202,7 +202,10 @@ export function legacyResolveLocalConfigValues( const jwtSecret = resolveJwtSecret( envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret), ); - const signingKeysPath = config.auth.signing_keys_path; + const signingKeysPath = envOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + ); const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 36c6d71f5f..e7c2b46315 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -27,6 +27,12 @@ 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)); +} + describe("legacyResolveLocalConfigValues", () => { it("derives every URL from api.external_url when unset", () => { const config = baseConfig(); @@ -136,6 +142,8 @@ describe("legacyResolveLocalConfigValues", () => { }); describe("SUPABASE_AUTH_* env overrides", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-env-override-test-"); + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() // (pkg/config/config.go:529-535) — env vars take precedence over config.toml. const ENV_KEYS = [ @@ -144,6 +152,7 @@ describe("legacyResolveLocalConfigValues", () => { "SUPABASE_AUTH_SECRET_KEY", "SUPABASE_AUTH_ANON_KEY", "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", ] as const; afterEach(() => { @@ -193,17 +202,39 @@ describe("legacyResolveLocalConfigValues", () => { LegacyInvalidJwtSecretError, ); }); + + 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 = legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [header] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ + kid: "env-kid", + }); + }); }); describe("auth.signing_keys_path (asymmetric JWT signing)", () => { const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); - function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); - } - it("signs anon/service_role with the first RS256 key in the file", async () => { const jwk = generateRsaJwk(); writeSigningKeys(tempRoot.current, [jwk]); From fb101635cd85d854d21dda0bc678aec45d74e8d3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 16:09:09 +0100 Subject: [PATCH 17/81] fix(cli): resolve SUPABASE_PROJECT_ID from supabase/.env for stop and status (review: PRRT_kwDOErm0O86N7ctY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load runs loadNestedEnv (supabase/.env and .env.local via godotenv.Load, which never overrides an already-set var) before loadFromFile wires up Viper's AutomaticEnv (pkg/config/config.go:735-745, 528-535) — so an env-file-only SUPABASE_PROJECT_ID overrides config.toml's project_id too, not just an ambient shell export. Both handlers only read process.env directly, missing that middle step: an env-named stack could be left running by stop, or status could target the wrong container. @supabase/config's loadProjectEnvironment already implements the same "ambient wins over .env/.env.local" layering (used today only for env() interpolation inside config.toml), so both handlers now resolve SUPABASE_PROJECT_ID through it instead of process.env directly, and pass the same resolved environment into loadProjectConfig so the .env files aren't parsed twice. Falls back to process.env directly when no supabase/config.toml exists anywhere (loadProjectEnvironment resolves to null in that case), preserving the existing ambient-only behavior for that scenario. The identical gap in LegacyCliConfig.projectId (used by several other already-ported commands) is out of scope here and left for a follow-up. --- .../legacy/commands/status/status.handler.ts | 23 +++++++-- .../status/status.integration.test.ts | 42 ++++++++++++++++ .../src/legacy/commands/stop/stop.handler.ts | 31 ++++++++++-- .../commands/stop/stop.integration.test.ts | 48 +++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 06d1030496..f10317706b 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,4 +1,4 @@ -import { loadProjectConfig, ProjectConfigSchema } from "@supabase/config"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, Option, Schema } from "effect"; @@ -101,7 +101,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // Mirror that by decoding an empty document through the schema for its // defaults (matching `packages/config/src/functions-manifest.ts`'s // `decodeProjectConfig({})` pattern) instead of failing. - const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv ?? undefined, + }).pipe( Effect.mapError( (cause) => new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), @@ -115,10 +126,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // (`pkg/config/config.go:938-944`) — every reader, including the Docker // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same // sanitized string, so `status` must filter on it too (see - // `legacyCliProjectFilterValue`'s doc comment). + // `legacyCliProjectFilterValue`'s doc comment). "env" is Go's + // post-`loadNestedEnv` value (`supabase/.env`/`.env.local`, ambient wins) — + // see `stop.handler.ts`'s `resolveSearchProjectIdFilter` doc comment for + // the full precedence chain and why `loadProjectEnvironment` is used here + // instead of reading `process.env` directly. const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - process.env["SUPABASE_PROJECT_ID"], + projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], config.project_id, cliConfig.workdir, ), diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 8f5174a557..44b4b9a368 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -357,6 +357,48 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("env-file-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("env-file-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("ambient-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("ambient-project")); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + it.live("fails when both docker and podman are missing", () => { // Neither container runtime can be spawned at all — distinct from a spawned // process exiting non-zero (covered by the malformed/unhealthy scenarios diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index a660d8fe8f..43332e8734 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,4 +1,4 @@ -import { loadProjectConfig } from "@supabase/config"; +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, Option, Result } from "effect"; @@ -37,6 +37,19 @@ import { * config.toml; otherwise `flags.LoadConfig` reads config.toml and * `Config.ProjectId` (env → toml → workdir basename) is used. * + * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell + * environment: `Config.Load` loads `supabase/.env`/`.env.local` into the + * process env via `godotenv.Load` (`pkg/config/config.go:735-738`; godotenv + * never overrides an already-set var) *before* Viper's `AutomaticEnv` reads + * `SUPABASE_PROJECT_ID` (`config.go:534-535`) — so an env-file-only value + * overrides config.toml too, not only an ambient shell export. + * `loadProjectEnvironment` already implements that exact "ambient wins over + * .env/.env.local" layering, so it's used here instead of reading + * `process.env` directly. It resolves to `null` when no `supabase/` + * config file exists anywhere up the tree, which would otherwise also drop + * the ambient-only case — falling back to `process.env` directly covers that + * gap without duplicating the "no config.toml" path's own error handling. + * * The config/env-derived (default) branch is sanitized with * {@link legacySanitizeProjectId} before it's used as a filter value, * matching Go's `Config.Validate` sanitizing the `Config.ProjectId` @@ -59,18 +72,30 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject return flags.projectId.value; } + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still // resolves a project id via the workdir basename default. Only a // malformed file (`loadProjectConfig` failing rather than returning // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. - const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv ?? undefined, + }).pipe( Effect.mapError( (cause) => new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); const resolved = legacyResolveLocalProjectId( - process.env["SUPABASE_PROJECT_ID"], + projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 4c3ddd3264..d97bc8afde 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -33,6 +33,12 @@ function writeConfig(workdir: string, projectId: string) { writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); } +function writeEnvFile(workdir: string, fileName: ".env" | ".env.local", contents: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, fileName), contents); +} + interface SpawnRecord { readonly command: string; readonly args: ReadonlyArray; @@ -346,6 +352,48 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=env-file-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=ambient-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { From 23951871d37dc28c67b408f0988c49df13c11c3f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 07:59:33 +0100 Subject: [PATCH 18/81] fix(cli): omit Docker-only --all from Podman's volume prune in stop (review: PRRT_kwDOErm0O86N8liv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No released Podman `volume prune` (checked v4.3 through the current v5.7) accepts `--all` — only `--filter`/`--force`/`--help` — so the Podman-CLI fallback `spawnContainerCli` already provides for Docker-less hosts would hard-fail after containers are already stopped. Podman already prunes every unused volume by default, so dropping `--all` on that path is lossless. Not a Go-parity gap (Go talks to the Docker Engine API directly and never shells out to a `docker`/`podman` binary), but a real bug in this port's own Podman fallback, confirmed via the existing "falls back to podman" coverage in stop.integration.test.ts. --- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 12 ++++++--- .../src/legacy/commands/stop/stop.handler.ts | 23 ++++++++++------ .../commands/stop/stop.integration.test.ts | 26 +++++++++++++++++++ .../src/legacy/shared/legacy-container-cli.ts | 9 ++++++- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 2711306a5e..0b2528fb2f 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -102,10 +102,14 @@ Same payload as `json`, delivered as a `result` NDJSON event. regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = flags.noBackup`. `--backup=false` alone does **not** delete volumes; only `--no-backup` does. -- Volume prune always passes `--all`. Go gates that flag on Docker engine >= 1.42 - (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips the - version check and always passes `--all` because every currently supported Docker - version is far past 1.42. +- Volume prune always passes `--all` on Docker. Go gates that flag on Docker engine >= + 1.42 (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips + the version check and always passes `--all` because every currently supported Docker + version is far past 1.42. On the Podman fallback, `--all` is omitted instead: no + released Podman `volume prune` (checked v4.3 through the current v5.7) accepts that + flag, and Podman already prunes every unused volume by default, so dropping it there + is lossless. Podman itself is a TS-only fallback (Go never shells out to a + `docker`/`podman` binary), so this has no Go-parity implication either way. - Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before failing the command (rather than stopping at the first failure), matching Go's diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 43332e8734..5c3acf1254 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -197,14 +197,21 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF // Go gates the `--all` filter arg on Docker engine >= 1.42 (`docker.go:120-124`). // All currently supported Docker versions are well past 1.42, so the TS port // always passes `--all` — documented divergence, see SIDE_EFFECTS.md Notes. - const volumePruneExitCode = yield* containerCliExitCode(spawner, [ - "volume", - "prune", - "--force", - "--all", - "--filter", - `label=${filterValue}`, - ]).pipe( + // + // Podman is a Docker-CLI-compatible fallback this port adds, not something + // Go itself has (Go talks to the Docker Engine API directly, never a + // `docker`/`podman` binary), so there's no Go behavior to match here — but + // `--all` isn't a real flag on any released Podman `volume prune` (only + // `--filter`/`--force`/`--help`, checked v4.3 through the current v5.7; + // `--all` only exists in unreleased dev docs), so it hard-fails on a real + // Podman-only host. Podman already prunes every unused volume by default, + // so omitting `--all` on the Podman fallback is a lossless fix. + const volumePruneExitCode = yield* containerCliExitCode( + spawner, + ["volume", "prune", "--force", "--all", "--filter", `label=${filterValue}`], + undefined, + ["volume", "prune", "--force", "--filter", `label=${filterValue}`], + ).pipe( Effect.mapError( (cause) => new LegacyStopVolumePruneError({ diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index d97bc8afde..2f6cb42f02 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -630,6 +630,32 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("omits --all from podman's volume prune (not a real Podman flag)", () => { + // No released Podman `volume prune` accepts `--all` (only `--filter`/`--force`/ + // `--help`), so passing Docker's `--all` argv straight through to the Podman + // fallback would hard-fail after containers are already stopped. Podman prunes + // every unused volume by default, so dropping `--all` there is lossless. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePruneCalls = child.spawned.filter( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePruneCalls.at(-1)?.command).toBe("podman"); + expect(volumePruneCalls.at(-1)?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a machine result in json mode without spinner text", () => { const { layer, out } = setup({ format: "json", diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 20a7a20654..e20a43c078 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -72,18 +72,25 @@ export const spawnContainerCli = ( /** * Run a container-CLI command and resolve to its exit code, mirroring the * spawner's `exitCode` convenience for callers that only need the status. + * + * `podmanArgs` lets a caller pass different argv to the Podman fallback than to + * Docker, for the rare case where the two aren't drop-in compatible on a given + * subcommand (e.g. `volume prune --all` — Docker-only, see + * `stop.handler.ts`'s volume-prune call). Defaults to reusing `args` unchanged, + * which is correct for every other call site. */ export const containerCliExitCode = ( spawner: Spawner, args: ReadonlyArray, options?: ChildProcess.CommandOptions, + podmanArgs?: ReadonlyArray, ) => spawner .exitCode(ChildProcess.make("docker", args, options)) .pipe( Effect.catch(() => spawner - .exitCode(ChildProcess.make("podman", args, options)) + .exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)) .pipe( Effect.catch(() => Effect.fail( From c0d1e5d238bd7707b81958b5836e9b93f4050e91 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 08:00:07 +0100 Subject: [PATCH 19/81] fix(cli): resolve project-root/SUPABASE_ENV dotenv values in stop/status (review: PRRT_kwDOErm0O86N8lio, PRRT_kwDOErm0O86N8lik) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load runs loadNestedEnv (pkg/config/config.go:1169-1207) before Viper's AutomaticEnv reads any SUPABASE_* override: it loads not just supabase/.env(.local) but also project-root and SUPABASE_ENV-selected dotenv files (.env..local, .env.local, .env., .env), via godotenv.Load, which never overrides an already-set var. `loadProjectEnvironment` only covered the supabase/-dir, plain .env/.env.local half of that. Add `legacyResolveProjectEnvironmentValues` (apps/cli/src/legacy/shared/) to fill the gap locally for stop/status rather than extending `loadProjectEnvironment` itself, which is shared infrastructure used well beyond legacy/ (the next/ command tree, secrets set) — out of scope for a stop/status port, consistent with this PR's existing precedent (deaf1e66) of flagging genuinely cross-cutting @supabase/config gaps rather than folding them in. Wire the enriched map into both: - stop/status's SUPABASE_PROJECT_ID resolution, so a value that only lives in a project-root or SUPABASE_ENV-selected file is no longer missed. - status's SUPABASE_AUTH_* key overrides (legacyResolveLocalConfigValues), which previously only checked ambient process.env, missing the same class of dotenv-only values for JWT secret, signing keys path, and the publishable/secret/anon/service-role key overrides. --- .../legacy/commands/status/status.handler.ts | 26 ++-- .../status/status.integration.test.ts | 37 ++++++ .../legacy/commands/status/status.values.ts | 13 +- .../src/legacy/commands/stop/stop.handler.ts | 25 ++-- .../commands/stop/stop.integration.test.ts | 20 +++ .../shared/legacy-local-config-values.ts | 29 +++-- .../shared/legacy-project-environment.ts | 114 +++++++++++++++++ .../legacy-project-environment.unit.test.ts | 115 ++++++++++++++++++ 8 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-project-environment.ts create mode 100644 apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index f10317706b..eec5b04ecb 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -25,6 +25,7 @@ import { encodeYaml, } from "../../shared/legacy-go-output.encoders.ts"; import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; import { LegacyStatusConfigLoadError, @@ -120,20 +121,24 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + // `legacyResolveProjectEnvironmentValues` fills the gap between + // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's + // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected + // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for + // the full precedence chain. Used below both for `SUPABASE_PROJECT_ID` and + // for the `SUPABASE_AUTH_*` overrides `legacyResolveStatusState` reads. + const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); + // 2. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton Go's // `Config.Validate` produces once at config-load time // (`pkg/config/config.go:938-944`) — every reader, including the Docker // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same // sanitized string, so `status` must filter on it too (see - // `legacyCliProjectFilterValue`'s doc comment). "env" is Go's - // post-`loadNestedEnv` value (`supabase/.env`/`.env.local`, ambient wins) — - // see `stop.handler.ts`'s `resolveSearchProjectIdFilter` doc comment for - // the full precedence chain and why `loadProjectEnvironment` is used here - // instead of reading `process.env` directly. + // `legacyCliProjectFilterValue`'s doc comment). const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], config.project_id, cliConfig.workdir, ), @@ -202,7 +207,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // service_role JWTs signed only once per invocation, not twice. const state = yield* Effect.try({ try: () => - legacyResolveStatusState(config, containerIds, hostname, excluded, cliConfig.workdir), + legacyResolveStatusState( + config, + containerIds, + hostname, + excluded, + cliConfig.workdir, + projectEnvValues, + ), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 44b4b9a368..e6cdc712df 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -399,6 +399,43 @@ describe("legacy status integration", () => { ); }); + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("root-env-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("root-env-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before AutomaticEnv reads SUPABASE_AUTH_JWT_SECRET (config.go:735-738) — + // a dotenv-file-only value must be visible here too, not just an ambient + // shell export (see the sibling "-o env" ambient test above). + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"c".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + it.live("fails when both docker and podman are missing", () => { // Neither container runtime can be spawned at all — distinct from a spawned // process exiting non-zero (covered by the malformed/unhealthy scenarios diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index f8165f4790..78f04770f2 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -249,8 +249,9 @@ export function legacyResolveStatusState( hostname: string, excluded: ReadonlyArray, workdir: string, + projectEnvValues: Readonly> | undefined = undefined, ): LegacyStatusState { - const local = legacyResolveLocalConfigValues(config, hostname, workdir); + const local = legacyResolveLocalConfigValues(config, hostname, workdir, projectEnvValues); const isExcluded = (id: string) => excluded.includes(id); const kongEnabled = @@ -355,7 +356,15 @@ export function legacyStatusValues( excluded: ReadonlyArray, overrides: ReadonlyMap, workdir: string, + projectEnvValues: Readonly> | undefined = undefined, ): LegacyStatusValuesResult { - const state = legacyResolveStatusState(config, containerIds, hostname, excluded, workdir); + const state = legacyResolveStatusState( + config, + containerIds, + hostname, + excluded, + workdir, + projectEnvValues, + ); return legacyStatusValuesFromState(state, overrides); } diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 5c3acf1254..0b7def8104 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -19,6 +19,7 @@ import { legacyListContainersByLabel, legacyListVolumesByLabel, } from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; import { LegacyStopConfigLoadError, @@ -38,17 +39,20 @@ import { * `Config.ProjectId` (env → toml → workdir basename) is used. * * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell - * environment: `Config.Load` loads `supabase/.env`/`.env.local` into the - * process env via `godotenv.Load` (`pkg/config/config.go:735-738`; godotenv - * never overrides an already-set var) *before* Viper's `AutomaticEnv` reads + * environment: `Config.Load` loads `supabase/.env`/`.env.local` *and* + * project-root/`SUPABASE_ENV`-selected dotenv files into the process env via + * `godotenv.Load` (`pkg/config/config.go:735-738,1169-1207`; godotenv never + * overrides an already-set var) *before* Viper's `AutomaticEnv` reads * `SUPABASE_PROJECT_ID` (`config.go:534-535`) — so an env-file-only value * overrides config.toml too, not only an ambient shell export. - * `loadProjectEnvironment` already implements that exact "ambient wins over - * .env/.env.local" layering, so it's used here instead of reading - * `process.env` directly. It resolves to `null` when no `supabase/` - * config file exists anywhere up the tree, which would otherwise also drop - * the ambient-only case — falling back to `process.env` directly covers that - * gap without duplicating the "no config.toml" path's own error handling. + * `legacyResolveProjectEnvironmentValues` implements that full precedence + * chain (see its doc comment) on top of `loadProjectEnvironment`'s + * `supabase/`-dir-only result, so it's used here instead of reading + * `process.env` directly. Both resolve to `undefined`/`null` when no + * `supabase/` config file exists anywhere up the tree, which would otherwise + * also drop the ambient-only case — falling back to `process.env` directly + * covers that gap without duplicating the "no config.toml" path's own error + * handling. * * The config/env-derived (default) branch is sanitized with * {@link legacySanitizeProjectId} before it's used as a filter value, @@ -94,8 +98,9 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); + const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); const resolved = legacyResolveLocalProjectId( - projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 2f6cb42f02..9a66847fdd 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -394,6 +394,26 @@ describe("legacy stop integration", () => { ); }); + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=root-env-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 1d8c9ed2b9..1ec3668695 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -96,9 +96,22 @@ const MIN_JWT_SECRET_LENGTH = 16; * same higher-than-config.toml precedence Viper gives env vars. An empty env * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never * enabled in `config.go`). + * + * Viper's `AutomaticEnv` binding runs AFTER `Config.Load`'s `loadNestedEnv` + * (`config.go:735-738`), which loads `supabase/.env`(.local) and project-root + * dotenv files into the process env before any `SUPABASE_*` var is read + * (`config.go:1169-1207`) — so a value that lives only in one of those files, + * not the ambient shell, must still be visible here. `projectEnvValues` is + * that already-resolved map (see `legacyResolveProjectEnvironmentValues`); + * falling back to `process.env` covers the "no `supabase/` project found" + * case, where `projectEnvValues` is `undefined`. */ -function envOverride(name: string, configured: string | undefined): string | undefined { - const value = process.env[name]; +function envOverride( + name: string, + configured: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + const value = projectEnvValues?.[name] ?? process.env[name]; return value !== undefined && value.length > 0 ? value : configured; } @@ -197,14 +210,16 @@ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, workdir: string, + projectEnvValues: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); const jwtSecret = resolveJwtSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret), + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), ); const signingKeysPath = envOverride( "SUPABASE_AUTH_SIGNING_KEYS_PATH", config.auth.signing_keys_path, + projectEnvValues, ); const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 @@ -221,22 +236,22 @@ export function legacyResolveLocalConfigValues( mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, publishableKey: resolveOpaqueKey( - envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key), + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues), defaultPublishableKey, ), secretKey: resolveOpaqueKey( - envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key), + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), defaultSecretKey, ), jwtSecret, anonKey: resolveSignedKey( - envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key), + envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), jwtSecret, signingKey, "anon", ), serviceRoleKey: resolveSignedKey( - envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key), + envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key, projectEnvValues), jwtSecret, signingKey, "service_role", diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts new file mode 100644 index 0000000000..f5c103a24f --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; + +/** + * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and Go's + * `loadNestedEnv` (`apps/cli-go/pkg/config/config.go:1169-1190`). Go's version + * walks not just `supabase/` but one directory further, up to the project + * root/workdir (the loop stops once `cwd == filepath.Dir(repoDir)`, i.e. after + * exactly two directories: `supabase/`, then its parent), and at each + * directory calls `loadDefaultEnv` (`config.go:1192-1207`), which loads dotenv + * files chosen by `SUPABASE_ENV` (empty/unset defaults to `"development"`, + * `config.go:1193-1195`): `.env..local`, `.env.local` (skipped when + * `env === "test"`), `.env.`, `.env` — via `godotenv.Load`, which only + * sets a key if it isn't already present in the process environment + * (`godotenv@v1.5.1/godotenv.go:184-204`, `overload: false`). Because + * `godotenv.Load` writes straight into the process env as it goes, the net + * precedence (highest first) is: ambient shell env > `supabase/`-dir dotenv + * files (`.local` variant before non-local, env-specific before bare `.env`) + * > project-root dotenv files (same internal order). + * + * `loadProjectEnvironment` only implements the `supabase/`-dir, plain + * `.env`/`.env.local` half of this (no project-root pass, no `SUPABASE_ENV` + * filename selection) — and it's shared infrastructure used well beyond + * `legacy/` (the `next/` command tree, `secrets set`), so extending its + * file-resolution semantics is out of scope for a `stop`/`status` port. + * Instead, this fills in the missing project-root + `SUPABASE_ENV`-selected + * files locally: `loadProjectEnvironment`'s already-resolved `values` (its + * ambient-wins-over-`supabase/.env`(.local) result) always takes precedence + * over anything discovered here, since it's already correct for the keys it + * knows about. + */ +function candidateDotenvFilenames(env: string): ReadonlyArray { + return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; +} + +/** + * Minimal `KEY=VALUE` dotenv reader, intentionally not reusing + * `@supabase/config`'s Effect-based `FileSystem` parser: this module stays a + * plain synchronous helper (like `legacy-local-config-values.ts`'s + * `loadFirstSigningKey`) since it only needs a handful of extra files read + * once per `stop`/`status` invocation. Quoting/escaping matches + * `packages/config/src/project.ts`'s `parseDotEnv` closely enough for the env + * vars this is used for (`SUPABASE_PROJECT_ID`, `SUPABASE_AUTH_*`), which + * never need the full dotenv spec (multiline values, `export` re-declares). + */ +function readDotEnvFile(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + + const contents = readFileSync(path, "utf8"); + const values: Record = {}; + + for (const rawLine of contents.split(/\r\n?|\n/)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + + const match = /^(?:export\s+)?([\w.-]+)\s*=(.*)$/.exec(line); + if (match === null) continue; + const key = match[1]; + if (key === undefined) continue; + + let value = (match[2] ?? "").trim(); + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) { + value = value.slice(1, -1); + if (quote === '"') { + value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); + } + } else { + const commentIndex = value.indexOf("#"); + if (commentIndex >= 0) value = value.slice(0, commentIndex).trim(); + } + + values[key] = value; + } + + return values; +} + +/** + * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` + * overrides (project id, auth fields) from — `projectEnv.values` (ambient + + * `supabase/.env`(.local), already correct) layered over the project-root and + * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover. + * Returns `undefined` when `projectEnv` is `null` (no `supabase/` project + * found), matching callers' existing "fall back to `process.env` directly" + * behavior. + */ +export function legacyResolveProjectEnvironmentValues( + projectEnv: ProjectEnvironment | null, +): Record | undefined { + if (projectEnv === null) return undefined; + + const env = process.env["SUPABASE_ENV"] || "development"; + const filenames = candidateDotenvFilenames(env); + const merged: Record = {}; + + // supabase/ dir first, then its parent (the project root) — matching Go's + // directory walk order. Within a directory, `godotenv.Load`'s "never + // override an already-set var" means first-processed-wins, so the plain + // merge below (skip keys already present) reproduces both orderings at once. + for (const dir of [projectEnv.paths.supabaseDir, projectEnv.paths.projectRoot]) { + for (const filename of filenames) { + const parsed = readDotEnvFile(join(dir, filename)); + if (parsed === undefined) continue; + for (const [key, value] of Object.entries(parsed)) { + if (!(key in merged)) merged[key] = value; + } + } + } + + return { ...merged, ...projectEnv.values }; +} diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts new file mode 100644 index 0000000000..0dc5c62571 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -0,0 +1,115 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; + +let root: string; +let supabaseDir: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "supabase-legacy-project-env-")); + supabaseDir = join(root, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + delete process.env["SUPABASE_ENV"]; +}); + +function fakeProjectEnv(values: Record = {}): ProjectEnvironment { + return { + paths: { + projectRoot: root, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + envPath: join(supabaseDir, ".env"), + envLocalPath: join(supabaseDir, ".env.local"), + }, + values, + loadedPaths: [], + sources: {}, + }; +} + +describe("legacyResolveProjectEnvironmentValues", () => { + it("returns undefined when no project was found", () => { + expect(legacyResolveProjectEnvironmentValues(null)).toBeUndefined(); + }); + + it("returns just the already-loaded values when no extra dotenv files exist", () => { + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); + expect(legacyResolveProjectEnvironmentValues(projectEnv)).toEqual({ + SUPABASE_PROJECT_ID: "from-loader", + }); + }); + + it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + }); + + it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets already-resolved projectEnv.values win over anything discovered locally", () => { + // `projectEnv.values` already reflects loadProjectEnvironment's correct + // ambient-wins-over-supabase/.env(.local) result; a redundant root .env + // entry for the same key must never override it. + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); + const merged = legacyResolveProjectEnvironmentValues(projectEnv); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("defaults SUPABASE_ENV to development when unset", () => { + writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }); + + it("selects the SUPABASE_ENV-named file over the bare .env file", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }); + + it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + }); + + it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { + process.env["SUPABASE_ENV"] = "test"; + writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); + writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("test-project"); + }); + + it("strips quotes the same way the shared dotenv parser does", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + }); + + it("ignores blank lines and comments", () => { + writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }); +}); From 8cf349e8c2d1b92324bebbb52cc70f61e30d32aa Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 21:26:10 +0100 Subject: [PATCH 20/81] fix(cli): preserve env-specific dotenv precedence and fail on malformed extra dotenv files in stop/status (review: PRRT_kwDOErm0O86OHamj, PRRT_kwDOErm0O86OHaml) `legacyResolveProjectEnvironmentValues` unconditionally overlaid the entire `projectEnv.values` map over the locally-derived `merged` precedence, letting a plain `supabase/.env` value (which `loadProjectEnvironment` has no way to mark as lower priority) clobber a correctly-resolved `.env..local` value. Only ambient-sourced entries (`projectEnv.sources[key] === "ambient"`) now override `merged` - matching Go's `godotenv.Load`, where ambient always wins but files are first-processed-wins (pkg/config/config.go:1169-1207). `readDotEnvFile` also silently skipped unmatched lines instead of failing, diverging from Go's `loadEnvIfExists` (config.go:1209-1234), which propagates `godotenv`'s parse error up through `loadNestedEnv` and fails `Config.Load` before `stop`/`status` touch Docker - and from this repo's own `parseDotEnv`, which already fails the same way for `supabase/.env`(.local). --- .../shared/legacy-project-environment.ts | 40 ++++++++++++--- .../legacy-project-environment.unit.test.ts | 49 ++++++++++++++++++- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index f5c103a24f..7da708aba6 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -44,19 +44,29 @@ function candidateDotenvFilenames(env: string): ReadonlyArray { * `packages/config/src/project.ts`'s `parseDotEnv` closely enough for the env * vars this is used for (`SUPABASE_PROJECT_ID`, `SUPABASE_AUTH_*`), which * never need the full dotenv spec (multiline values, `export` re-declares). + * + * @throws on a line that isn't blank, a comment, or a `KEY=VALUE` assignment — + * matching Go's `loadEnvIfExists` (`pkg/config/config.go:1209-1234`), which + * propagates `godotenv.Load`'s parse error up through `loadNestedEnv` and fails + * `Config.Load` before `stop`/`status` touch Docker, rather than silently + * skipping the bad line. Mirrors `packages/config/src/project.ts`'s + * `parseDotEnv`, which already fails the same way for `supabase/.env`(.local). */ function readDotEnvFile(path: string): Record | undefined { if (!existsSync(path)) return undefined; const contents = readFileSync(path, "utf8"); const values: Record = {}; + const lines = contents.split(/\r\n?|\n/); - for (const rawLine of contents.split(/\r\n?|\n/)) { + for (const [index, rawLine] of lines.entries()) { const line = rawLine.trim(); if (line === "" || line.startsWith("#")) continue; const match = /^(?:export\s+)?([\w.-]+)\s*=(.*)$/.exec(line); - if (match === null) continue; + if (match === null) { + throw new Error(`failed to parse environment file: ${path} (line ${index + 1})`); + } const key = match[1]; if (key === undefined) continue; @@ -80,9 +90,20 @@ function readDotEnvFile(path: string): Record | undefined { /** * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` - * overrides (project id, auth fields) from — `projectEnv.values` (ambient + - * `supabase/.env`(.local), already correct) layered over the project-root and - * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover. + * overrides (project id, auth fields) from — the project-root and + * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover, layered + * under only the truly ambient-sourced entries of `projectEnv.values`. + * + * Only `projectEnv`'s AMBIENT entries outrank `merged`: `projectEnv.values` + * also carries plain `supabase/.env`/`.env.local` values it read itself, and + * those are not necessarily higher Go precedence than an env-specific file + * (`.env..local`/`.env.`) `merged` resolved — `loadProjectEnvironment` + * has no notion of `SUPABASE_ENV`-selected filenames, so it can't tell the two + * apart itself. `merged`'s own walk below already re-derives the full file + * precedence, including `supabase/.env`(.local), so only ambient needs to be + * layered back on top (`projectEnv.sources[key] === "ambient"` marks exactly + * those entries — see `loadProjectEnvironment`'s `ProjectEnvironment` shape). + * * Returns `undefined` when `projectEnv` is `null` (no `supabase/` project * found), matching callers' existing "fall back to `process.env` directly" * behavior. @@ -110,5 +131,12 @@ export function legacyResolveProjectEnvironmentValues( } } - return { ...merged, ...projectEnv.values }; + const ambientOverrides: Record = {}; + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } + } + + return { ...merged, ...ambientOverrides }; } diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index 0dc5c62571..f0c056693f 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -21,7 +21,10 @@ afterEach(() => { delete process.env["SUPABASE_ENV"]; }); -function fakeProjectEnv(values: Record = {}): ProjectEnvironment { +function fakeProjectEnv( + values: Record = {}, + sources: Record = {}, +): ProjectEnvironment { return { paths: { projectRoot: root, @@ -32,7 +35,10 @@ function fakeProjectEnv(values: Record = {}): ProjectEnvironment }, values, loadedPaths: [], - sources: {}, + // Default every given value to "ambient" unless the caller says otherwise — + // matches how most tests use this helper (representing an already-resolved, + // highest-precedence value) without forcing every call site to spell it out. + sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), }; } @@ -112,4 +118,43 @@ describe("legacyResolveProjectEnvironmentValues", () => { const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("commented-project"); }); + + it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { + // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so + // a key it resolved from a plain supabase/.env file is NOT necessarily + // higher Go precedence than a same-named key from `.env..local` — + // only an "ambient" source outranks the file precedence computed locally. + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, + { SUPABASE_PROJECT_ID: ".env" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + }); + + it("still lets a truly ambient-sourced value win over any file", () => { + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "ambient-project" }, + { SUPABASE_PROJECT_ID: "ambient" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { + writeFileSync(join(root, ".env"), "not a valid line\n"); + expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv())).toThrow( + /failed to parse environment file/, + ); + }); }); From 3b114c27316b825b8cfd7de9e94989503a5f5c55 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 21:26:20 +0100 Subject: [PATCH 21/81] fix(cli): resolve project env values before decoding config.toml in stop/status (review: PRRT_kwDOErm0O86OHami) Go's `Config.Load` runs `loadNestedEnv` (which loads project-root and `SUPABASE_ENV`-selected dotenv files into the process env) before `LoadEnvHook` decodes `env(...)` references in config.toml (pkg/config/config.go:735-738). Both handlers computed the Go-parity env map (`legacyResolveProjectEnvironmentValues`) only AFTER calling `loadProjectConfig`, so an `env(...)` reference backed only by one of those extra dotenv files (e.g. `auth.jwt_secret = "env(AUTH_JWT_SECRET)"` set in `.env.development.local`) decoded to the literal unsubstituted string instead of the real value. Move the resolution earlier and feed the extended values into `loadProjectConfig`'s `projectEnv` option (spreading over the original object so only `.values` changes), so config decode sees the same env map Go's decoder would. Wrapped in `Effect.try` since `legacyResolveProjectEnvironmentValues` can now throw on a malformed dotenv file (PRRT_kwDOErm0O86OHaml), mapped to each command's existing config-load error. --- .../legacy/commands/status/status.handler.ts | 33 ++++++++++++++----- .../src/legacy/commands/stop/stop.handler.ts | 21 ++++++++++-- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index eec5b04ecb..1299d4c5b8 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -111,8 +111,31 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); + + // `legacyResolveProjectEnvironmentValues` fills the gap between + // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's + // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected + // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for + // the full precedence chain. Resolved BEFORE `loadProjectConfig` decodes + // config.toml (not after) because Go's `Config.Load` runs `loadNestedEnv` + // before `LoadEnvHook` decodes `env(...)` references (`config.go:735-738`); + // an `env(...)` value sourced only from a project-root/`SUPABASE_ENV`- + // selected file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID`/`SUPABASE_AUTH_*` overrides read further below. + // A malformed extra dotenv file throws here (see `readDotEnvFile`), + // matching Go's `loadNestedEnv` propagating `godotenv`'s parse error + // instead of silently skipping the bad line. + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv), + catch: (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + const loaded = yield* loadProjectConfig(cliConfig.workdir, { - projectEnv: projectEnv ?? undefined, + projectEnv: + projectEnv !== null + ? { ...projectEnv, values: projectEnvValues ?? projectEnv.values } + : undefined, }).pipe( Effect.mapError( (cause) => @@ -121,14 +144,6 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); - // `legacyResolveProjectEnvironmentValues` fills the gap between - // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's - // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected - // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for - // the full precedence chain. Used below both for `SUPABASE_PROJECT_ID` and - // for the `SUPABASE_AUTH_*` overrides `legacyResolveStatusState` reads. - const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); - // 2. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton Go's // `Config.Validate` produces once at config-load time diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 0b7def8104..6ccdabac4b 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -86,19 +86,36 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject ), ); + // Resolved BEFORE `loadProjectConfig` decodes config.toml (not after): + // Go's `Config.Load` runs `loadNestedEnv` before `LoadEnvHook` decodes + // `env(...)` references (`config.go:735-738`), so an `env(...)`-valued + // `project_id` sourced only from a project-root/`SUPABASE_ENV`-selected + // file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID` override read below. A malformed extra dotenv + // file throws here (see `readDotEnvFile`), matching Go's `loadNestedEnv` + // propagating `godotenv`'s parse error instead of silently skipping the + // bad line. + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv), + catch: (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still // resolves a project id via the workdir basename default. Only a // malformed file (`loadProjectConfig` failing rather than returning // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. const loaded = yield* loadProjectConfig(cliConfig.workdir, { - projectEnv: projectEnv ?? undefined, + projectEnv: + projectEnv !== null + ? { ...projectEnv, values: projectEnvValues ?? projectEnv.values } + : undefined, }).pipe( Effect.mapError( (cause) => new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); - const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); const resolved = legacyResolveLocalProjectId( projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, From f9768ba79f2632c667da5d56f15df64748693809 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 21:26:32 +0100 Subject: [PATCH 22/81] fix(cli): skip signing-key file reads in status when auth is disabled (review: PRRT_kwDOErm0O86OHamk) Go's `Config.Validate` only opens/parses `Auth.SigningKeysPath` inside `if c.Auth.Enabled` (pkg/config/config.go:1036,1059-1065), so a disabled auth section never touches that file, however stale or missing it is. `legacyResolveLocalConfigValues` called `loadFirstSigningKey` unconditionally whenever `signing_keys_path` was set, so `status` failed on valid local configs where auth is disabled but the path points nowhere. Gate the file read on `config.auth.enabled`; JWT-secret validation and anon/service_role key generation (`generateAPIKeys`, apikeys.go:43-73) stay unconditional, matching Go running that step outside the `Auth.Enabled` block. --- .../shared/legacy-local-config-values.ts | 13 ++++++++- .../legacy-local-config-values.unit.test.ts | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 1ec3668695..dd44153565 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -175,6 +175,11 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ * `JSON.parse`'s raw Node error text through unwrapped. + * + * Callers must only invoke this when `config.auth.enabled` is true — Go's + * `Validate` nests the entire signing-keys read inside `if c.Auth.Enabled` + * (`pkg/config/config.go:1036,1059-1065`), so a disabled auth section never + * touches `signing_keys_path`, however stale or missing that file is. */ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { const absolutePath = isAbsolute(signingKeysPath) @@ -221,8 +226,14 @@ export function legacyResolveLocalConfigValues( config.auth.signing_keys_path, projectEnvValues, ); + // Gated on `auth.enabled` to match Go's `Validate` (`pkg/config/config.go:1036,1059-1065`): + // the signing-keys file read lives entirely inside `if c.Auth.Enabled`, so a + // disabled auth section never opens/parses `signing_keys_path`, even a stale + // or missing one. JWT-secret validation and anon/service_role key generation + // (`generateAPIKeys`, `apikeys.go:43-73`) run unconditionally either way, so + // only this file read is gated. const signingKey = - signingKeysPath !== undefined && signingKeysPath.length > 0 + config.auth.enabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index e7c2b46315..6fdf15c576 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -316,5 +316,32 @@ describe("legacyResolveLocalConfigValues", () => { "unsupported algorithm: RS512", ); }); + + // Go's `Validate` only opens/parses `signing_keys_path` inside + // `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`) — a disabled + // auth section never touches the file, however stale or missing it is. + it("skips reading a missing signing_keys_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("skips reading a malformed signing_keys_path when auth is disabled", () => { + 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 = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Falls back to HMAC signing, matching an absent signing key. + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); }); }); From 40e948dd071f97309e4a2226ed3426d47f195cd5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 11:15:25 +0100 Subject: [PATCH 23/81] fix(cli): expand $VAR/${VAR} refs in stop/status dotenv files to match Go (review: #3527882515) godotenv (which Go's Config.Load calls for supabase/- and project-root-level env files) expands unquoted/double-quoted $VAR/${VAR} references against earlier keys in the same file before loadNestedEnv finishes; the TS readDotEnvFile stored the literal reference instead, so stop's Docker filter and status's key output could resolve the wrong SUPABASE_PROJECT_ID/ SUPABASE_AUTH_* value whenever a dotenv file composed one var from another. --- .../shared/legacy-project-environment.ts | 17 +++++++++++ .../legacy-project-environment.unit.test.ts | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index 7da708aba6..b94e494eb5 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -35,6 +35,21 @@ function candidateDotenvFilenames(env: string): ReadonlyArray { return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; } +// Mirrors godotenv's `expandVariables` (`godotenv@v1.5.1/parser.go:253,257-271`): substitutes +// `$VAR`/`${VAR}` using only keys already parsed earlier in *this* file (godotenv re-parses +// each dotenv file into its own fresh map — it never sees a different file's keys or the +// ambient shell env, `parser.go:20-45`). Called for unquoted and double-quoted values only +// (`parser.go:157,174-178`); single-quoted values never reach this (`parser.go:172-173`). An +// unresolved reference expands to `""` (Go's zero value for a missing map key), not the +// literal `$NAME`. +function expandDotEnvVariable(value: string, values: Readonly>): string { + return value.replace( + /\$\{([A-Z0-9_]+)\}|\$([A-Z0-9_]+)/g, + (_match, braced: string | undefined, bare: string | undefined) => + values[braced ?? bare ?? ""] ?? "", + ); +} + /** * Minimal `KEY=VALUE` dotenv reader, intentionally not reusing * `@supabase/config`'s Effect-based `FileSystem` parser: this module stays a @@ -76,10 +91,12 @@ function readDotEnvFile(path: string): Record | undefined { value = value.slice(1, -1); if (quote === '"') { value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); + value = expandDotEnvVariable(value, values); } } else { const commentIndex = value.indexOf("#"); if (commentIndex >= 0) value = value.slice(0, commentIndex).trim(); + value = expandDotEnvVariable(value, values); } values[key] = value; diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index f0c056693f..fe72610ff4 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -157,4 +157,32 @@ describe("legacyResolveProjectEnvironmentValues", () => { /failed to parse environment file/, ); }); + + it("expands an unquoted $VAR reference to an earlier value in the same file", () => { + // godotenv expands unquoted/double-quoted references while loading + // (`godotenv@v1.5.1/parser.go:157`), so a later key can reuse an earlier one. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("expands a braced ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + }); + + it("does not expand variable references inside single-quoted values", () => { + // godotenv never calls expandVariables for single-quoted values + // (`parser.go:172-173`) — they stay byte-literal. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + }); + + it("expands an unresolved reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe(""); + }); }); From a9d944cd2a12108e405546b720ccd9b2b2fe23f3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 11:15:43 +0100 Subject: [PATCH 24/81] fix(cli): harden stop's Docker CLI calls against pipe deadlock and pre-1.42 hosts (review: #3527882504, #3527882512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues in the ported `stop` handler that Go's direct Engine-API client never hits since it doesn't shell out to a CLI at all: - Every exit-code-only containerCliExitCode call (stop, container/network prune, volume prune) left stdout/stderr on the default "pipe" stdio without draining them. A host with many stale containers can fill the OS pipe buffer (e.g. container prune's "Deleted Containers" list under `stop --all`), blocking the child and hanging stop. Pass stdio: "ignore" on all four, matching the existing precedent in legacy-pgdelta.seam.layer.ts. - Volume prune always passed --all unconditionally. Docker CLI's own --all flag on `volume prune` requires API >= 1.42 and is rejected by Cobra's Args validator before anything is pruned, so on an older daemon this hard-failed the whole call instead of degrading gracefully — exactly the case Go avoids by gating the equivalent filter on Docker.ClientVersion() >= "1.42" (docker.go:126-133). Add legacyDockerSupportsVolumePruneAllFlag, which asks `docker version` for the negotiated API version and mirrors Go's gate. Podman's argv is unaffected on both fixes: it already gets its own --filter/--force-only argv (no --all — not a real Podman flag) via containerCliExitCode's podmanArgs parameter. --- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 19 +++-- .../src/legacy/commands/stop/stop.handler.ts | 72 +++++++++++------ .../commands/stop/stop.integration.test.ts | 57 +++++++++++++- .../src/legacy/shared/legacy-container-cli.ts | 69 +++++++++++++++- .../shared/legacy-container-cli.unit.test.ts | 78 ++++++++++++++++++- 5 files changed, 262 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 0b2528fb2f..300c06c297 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -102,14 +102,17 @@ Same payload as `json`, delivered as a `result` NDJSON event. regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = flags.noBackup`. `--backup=false` alone does **not** delete volumes; only `--no-backup` does. -- Volume prune always passes `--all` on Docker. Go gates that flag on Docker engine >= - 1.42 (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips - the version check and always passes `--all` because every currently supported Docker - version is far past 1.42. On the Podman fallback, `--all` is omitted instead: no - released Podman `volume prune` (checked v4.3 through the current v5.7) accepts that - flag, and Podman already prunes every unused volume by default, so dropping it there - is lossless. Podman itself is a TS-only fallback (Go never shells out to a - `docker`/`podman` binary), so this has no Go-parity implication either way. +- Volume prune gates `--all` on the Docker daemon's API version (`legacy-container-cli.ts`'s + `legacyDockerSupportsVolumePruneAllFlag`, checked via `docker version --format +'{{.Server.APIVersion}}'`), matching Go's `Docker.ClientVersion() >= "1.42"` check + (`docker.go:126-133`) exactly. This isn't cosmetic: Docker CLI's own `--all` flag on + `volume prune` is annotated `version: "1.42"` and enforced by Cobra's `Args` validator + _before_ pruning runs, so sending it unconditionally on a pre-1.42 daemon hard-fails the + whole call instead of just pruning a narrower set. On the Podman fallback, `--all` is + omitted unconditionally instead: no released Podman `volume prune` (checked v4.3 through + the current v5.7) accepts that flag, and Podman already prunes every unused volume by + default, so dropping it there is lossless. Podman itself is a TS-only fallback (Go never + shells out to a `docker`/`podman` binary), so this has no Go-parity implication either way. - Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before failing the command (rather than stopping at the first failure), matching Go's diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 6ccdabac4b..3a1428cee3 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -9,6 +9,7 @@ import { legacyAqua } from "../../shared/legacy-colors.ts"; import { containerCliExitCode, legacyDescribeContainerCliFailure, + legacyDockerSupportsVolumePruneAllFlag, } from "../../shared/legacy-container-cli.ts"; import { legacyCliProjectFilterValue, @@ -176,8 +177,23 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF // Go stops containers concurrently via `WaitAll`, joining every failure // rather than short-circuiting on the first one (`docker.go:96-146`). + // + // `stdout`/`stderr: "ignore"` on every exit-code-only call below: none of + // these read the child's own output, and the default `"pipe"` stdio + // otherwise leaves an OS pipe unread — once `docker`/`podman` write + // enough to it (e.g. `container prune`'s "Deleted Containers" ID list on + // a host with many stale containers, most likely under `stop --all`), + // the child blocks on write() and `stop` hangs. Matches the existing + // `stdio: "ignore"` precedent for the same "exit-code-only" shape in + // `legacy-pgdelta.seam.layer.ts`. const stopResults = yield* Effect.all( - containerIds.map((id) => containerCliExitCode(spawner, ["stop", id]).pipe(Effect.result)), + containerIds.map((id) => + containerCliExitCode(spawner, ["stop", id], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.result), + ), { concurrency: "unbounded" }, ); const failedStop = stopResults.find( @@ -195,13 +211,11 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF ); } - const containerPruneExitCode = yield* containerCliExitCode(spawner, [ - "container", - "prune", - "--force", - "--filter", - `label=${filterValue}`, - ]).pipe( + const containerPruneExitCode = yield* containerCliExitCode( + spawner, + ["container", "prune", "--force", "--filter", `label=${filterValue}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe( Effect.mapError( (cause) => new LegacyStopContainerPruneError({ @@ -216,22 +230,38 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF } if (deleteVolumes) { - // Go gates the `--all` filter arg on Docker engine >= 1.42 (`docker.go:120-124`). - // All currently supported Docker versions are well past 1.42, so the TS port - // always passes `--all` — documented divergence, see SIDE_EFFECTS.md Notes. + // Go gates the `--all` filter arg on Docker API >= 1.42 + // (`docker.go:126-133`, `Docker.ClientVersion() >= "1.42"`): Docker + // CLI's own `volume prune --all` flag is annotated `version: "1.42"` + // (`docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and enforced + // by Cobra's `Args` validator *before* `RunE` runs + // (`cmd/docker/docker.go:659-660`) — on an older daemon, passing + // `--all` unconditionally would hard-fail this whole call and prune + // nothing, not just prune a narrower set. There's no persistent + // Engine API client here to ask the negotiated version directly (Go + // talks to the Docker Engine API, never a `docker` binary), so + // {@link legacyDockerSupportsVolumePruneAllFlag} asks the `docker` CLI + // itself via `docker version` and mirrors Go's gate exactly. // // Podman is a Docker-CLI-compatible fallback this port adds, not something - // Go itself has (Go talks to the Docker Engine API directly, never a - // `docker`/`podman` binary), so there's no Go behavior to match here — but + // Go itself has, so there's no Go behavior to match on that path — but // `--all` isn't a real flag on any released Podman `volume prune` (only // `--filter`/`--force`/`--help`, checked v4.3 through the current v5.7; // `--all` only exists in unreleased dev docs), so it hard-fails on a real // Podman-only host. Podman already prunes every unused volume by default, // so omitting `--all` on the Podman fallback is a lossless fix. + const dockerSupportsAll = yield* legacyDockerSupportsVolumePruneAllFlag(spawner); const volumePruneExitCode = yield* containerCliExitCode( spawner, - ["volume", "prune", "--force", "--all", "--filter", `label=${filterValue}`], - undefined, + [ + "volume", + "prune", + "--force", + ...(dockerSupportsAll ? ["--all"] : []), + "--filter", + `label=${filterValue}`, + ], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, ["volume", "prune", "--force", "--filter", `label=${filterValue}`], ).pipe( Effect.mapError( @@ -248,13 +278,11 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF } } - const networkPruneExitCode = yield* containerCliExitCode(spawner, [ - "network", - "prune", - "--force", - "--filter", - `label=${filterValue}`, - ]).pipe( + const networkPruneExitCode = yield* containerCliExitCode( + spawner, + ["network", "prune", "--force", "--filter", `label=${filterValue}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe( Effect.mapError( (cause) => new LegacyStopNetworkPruneError({ diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 9a66847fdd..fbf4668f59 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -141,18 +141,25 @@ function mockRoutedContainerCliSpawner( }; } -/** Default happy-path router: `ps` lists one container, everything else succeeds empty. */ +/** + * Default happy-path router: `ps` lists one container, `docker version` reports + * an API version comfortably at/above the `volume prune --all` gate (1.42, see + * `legacyDockerSupportsVolumePruneAllFlag`), everything else succeeds empty. + */ function defaultRoute( opts: { readonly containerIds?: ReadonlyArray; readonly volumeNames?: ReadonlyArray; + readonly dockerApiVersion?: string; } = {}, ) { const containerIds = opts.containerIds ?? ["c1"]; const volumeNames = opts.volumeNames ?? []; + const dockerApiVersion = opts.dockerApiVersion ?? "1.45"; return (args: ReadonlyArray): RouteResult => { if (args[0] === "ps") return { stdout: containerIds }; if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; + if (args[0] === "version") return { stdout: [dockerApiVersion] }; return { exitCode: 0 }; }; } @@ -446,6 +453,54 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", + () => { + // Docker CLI's own `volume prune --all` flag requires API >= 1.42 and + // hard-fails (pruning nothing) on an older daemon — Go avoids ever + // sending it by checking `Docker.ClientVersion() >= "1.42"` + // (docker.go:126-133). This mirrors that gate via `docker version`. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.41" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("includes --all in docker's volume prune when the API is exactly 1.42", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.42" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => { // Go's `--backup` is declared but never bound to a variable (`cmd/stop.go:26`) — // `RunE` always passes `!noBackup`, so `--backup=false` has zero effect in the diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index e20a43c078..53bfb6e947 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect"; +import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -100,3 +100,70 @@ export const containerCliExitCode = ( ), ), ); + +function collectDockerCliText(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +/** + * Mirrors Go's `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`, + * used by `apps/cli-go/internal/utils/docker.go:128`): splits each version on + * `.` and compares the parts numerically, component by component — not a + * naive string/float compare, which would misorder e.g. `"1.9"` vs `"1.10"`. + */ +function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean { + const parts = version.split(".").map((part) => Number.parseInt(part, 10)); + const minParts = minVersion.split(".").map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < Math.max(parts.length, minParts.length); index++) { + const part = parts[index] ?? 0; + const minPart = minParts[index] ?? 0; + if (part !== minPart) return part > minPart; + } + return true; +} + +/** + * Docker CLI's own `volume prune --all` flag is annotated `version: "1.42"` + * (vendored `docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and + * enforced by Cobra's `Args` validator *before* `RunE` runs + * (`cmd/docker/docker.go:659-660`): against a daemon with a lower negotiated + * API version, `docker volume prune --all ...` exits nonzero without pruning + * anything at all, rather than degrading gracefully. Go avoids ever hitting + * that by gating the equivalent `all=true` filter on + * `Docker.ClientVersion() >= "1.42"` (`docker.go:126-133`); there is no + * persistent Engine API client here to ask, so this asks the `docker` CLI + * itself via `docker version`. + * + * Deliberately does not fall back to Podman like {@link containerCliExitCode} + * does: Podman's `volume prune` never has an `--all` flag to gate in the + * first place (callers already omit it from their Podman argv + * unconditionally), so this check is meaningless on that path. Resolves to + * `false` (omit `--all`, matching how a pre-1.42 daemon's own `volume prune` + * already prunes every unused volume without it) whenever `docker` can't be + * spawned, its `version` call fails, or the reported version can't be read — + * the side that can never turn into a hard failure of the prune call itself. + */ +export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["version", "--format", "{{.Server.APIVersion}}"], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }), + ); + const [exitCode, stdout] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectDockerCliText(child.stdout)], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return false; + const version = stdout.trim(); + return version.length > 0 && isDockerApiVersionAtLeast(version, "1.42"); + }), + ).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index e10297b8e2..e80dbf5ae8 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -5,6 +5,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { containerCliExitCode, legacyDescribeContainerCliFailure, + legacyDockerSupportsVolumePruneAllFlag, spawnContainerCli, } from "./legacy-container-cli.ts"; @@ -13,6 +14,7 @@ function mockSpawner( readonly dockerMissing?: boolean; readonly bothMissing?: boolean; readonly exitCode?: number; + readonly stdout?: string; } = {}, ) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; @@ -39,7 +41,10 @@ function mockSpawner( return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.empty, + stdout: + opts.stdout !== undefined + ? Stream.fromIterable([new TextEncoder().encode(opts.stdout)]) + : Stream.empty, stderr: Stream.empty, all: Stream.empty, exitCode: Deferred.await(exitDeferred), @@ -122,6 +127,77 @@ describe("containerCliExitCode", () => { }); }); +describe("legacyDockerSupportsVolumePruneAllFlag", () => { + it.live("returns true when the daemon reports an API version at or above 1.42", () => { + const mock = mockSpawner({ stdout: "1.42" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + expect(mock.spawned).toEqual([ + { command: "docker", args: ["version", "--format", "{{.Server.APIVersion}}"] }, + ]); + }), + ); + }); + + it.live("returns true for a version comfortably above 1.42", () => { + const mock = mockSpawner({ stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + }), + ); + }); + + it.live("returns false when the daemon reports an API version below 1.42", () => { + const mock = mockSpawner({ stdout: "1.41" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("compares version components numerically, not lexicographically", () => { + // A naive string compare would misorder "1.9" as greater than "1.42" — this + // guards the numeric, component-by-component comparison instead. + const mock = mockSpawner({ stdout: "1.9" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the version command exits non-zero", () => { + const mock = mockSpawner({ exitCode: 1, stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the reported version is empty", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false without falling back to podman when docker cannot be spawned", () => { + const mock = mockSpawner({ dockerMissing: true }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + expect(mock.spawned.map((entry) => entry.command)).toEqual(["docker"]); + }), + ); + }); +}); + describe("legacyDescribeContainerCliFailure", () => { it.live("describes a both-runtimes-missing failure with its clear message", () => { const mock = mockSpawner({ bothMissing: true }); From 36e2ad25ca8a3bfbe16f13c7af6556de44fd4f72 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 11:45:04 +0100 Subject: [PATCH 25/81] fix(cli): preserve literal # in project-env dotenv values to match godotenv (review: #5765) readDotEnvFile truncated an unquoted value at the first `#` unconditionally, but Go's godotenv (pkg/config/config.go's loadNestedEnv chain) only starts an inline comment at a `#` preceded by whitespace. Values like SUPABASE_AUTH_JWT_SECRET=long#secret were silently truncated before status signs keys or stop builds the Docker label filter. Reuses the already-correct stripInlineComment helper from legacy-dotenv.ts instead of duplicating it. --- apps/cli/src/legacy/shared/legacy-dotenv.ts | 6 +++++- .../legacy/shared/legacy-project-environment.ts | 8 ++++++-- .../shared/legacy-project-environment.unit.test.ts | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-dotenv.ts b/apps/cli/src/legacy/shared/legacy-dotenv.ts index eef14a1691..744a36b00c 100644 --- a/apps/cli/src/legacy/shared/legacy-dotenv.ts +++ b/apps/cli/src/legacy/shared/legacy-dotenv.ts @@ -174,8 +174,12 @@ function extractVarValue( * Strip an unquoted inline comment, matching godotenv: scanning from the right, * a `#` preceded by whitespace begins a comment (`54323 # local` → `54323`), * while a `#` with no leading whitespace is part of the value (`foo#bar`). + * + * Exported for reuse by `legacy-project-environment.ts`'s `readDotEnvFile`, which + * needs the same whitespace-aware rule for the project-root + `SUPABASE_ENV`-selected + * dotenv files it reads (see that module's doc comment). */ -function stripInlineComment(value: string): string { +export function stripInlineComment(value: string): string { for (let i = value.length - 1; i > 0; i--) { if (value[i] === "#" && (value[i - 1] === " " || value[i - 1] === "\t")) { return value.slice(0, i); diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index b94e494eb5..8cdf14d6ff 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import type { ProjectEnvironment } from "@supabase/config"; +import { stripInlineComment } from "./legacy-dotenv.ts"; + /** * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and Go's * `loadNestedEnv` (`apps/cli-go/pkg/config/config.go:1169-1190`). Go's version @@ -94,8 +96,10 @@ function readDotEnvFile(path: string): Record | undefined { value = expandDotEnvVariable(value, values); } } else { - const commentIndex = value.indexOf("#"); - if (commentIndex >= 0) value = value.slice(0, commentIndex).trim(); + // godotenv only starts a comment at a `#` preceded by whitespace (see + // `stripInlineComment`'s doc comment); an unquoted `#bar` with no leading + // space is part of the value, e.g. `SUPABASE_PROJECT_ID=foo#bar`. + value = stripInlineComment(value).trim(); value = expandDotEnvVariable(value, values); } diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index fe72610ff4..c9e81a3c72 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -119,6 +119,20 @@ describe("legacyResolveProjectEnvironmentValues", () => { expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("commented-project"); }); + it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { + // godotenv only starts an inline comment at a `#` preceded by whitespace + // (`godotenv@v1.5.1/parser.go:144-153`); `foo#bar` keeps the `#` verbatim. + writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + }); + + it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("54323"); + }); + it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so // a key it resolved from a plain supabase/.env file is NOT necessarily From 11e89375ced99bf0ebfb90b1400e919c5b97aa4f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 11:45:10 +0100 Subject: [PATCH 26/81] fix(cli): let status -o pretty win over --output-format (review: #5765) status.handler.ts lumped an explicit `-o pretty` in with an absent -o flag and deferred to --output-format, so `status -o pretty --output-format json` emitted structured JSON instead of the human table. Go's --output is a complete format choice, and sibling ported commands (functions/list, branches/get) already give explicit -o pretty priority over --output-format per legacy/cli/root.ts's documented precedence. Adds the same goFmt === "pretty" branch ahead of the output.format check. --- .../legacy/commands/status/status.handler.ts | 42 ++++++++++++------- .../status/status.integration.test.ts | 13 ++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 1299d4c5b8..da7f37db81 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -237,8 +237,26 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS }); const { values } = legacyStatusValuesFromState(state, overrides); - // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over - // --output-format; -o pretty/unset falls through to text/json/stream-json. + // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty + // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the + // CLI-supplied, override-populated `names` — `--override-name` only ever + // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. + // Remap names from the already-resolved `state` (empty override map) so the + // rendered table matches Go exactly without leaking `--override-name` into + // pretty-mode output, and without a second (throwing) state resolution. + const renderPretty = Effect.fnUntraced(function* () { + yield* output.raw( + `${legacyAqua("supabase")} local development setup is running.\n\n`, + "stderr", + ); + const pretty = legacyStatusValuesFromState(state, new Map()); + yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + }); + + // 8. Output branching: Go's -o (env|json|toml|yaml|pretty) is a complete + // format choice and takes priority over --output-format (root.ts:119-121, + // matching functions/list's list.handler.ts:115-118) — only an ABSENT -o + // defers to --output-format for json/stream-json. const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "env") { @@ -257,26 +275,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS yield* output.raw(encodeYaml(values)); return; } + if (goFmt === "pretty") { + yield* renderPretty(); + return; + } - // goFmt is undefined or "pretty" — defer to TS --output-format for json/stream-json, + // goFmt is undefined — defer to TS --output-format for json/stream-json, // otherwise render the grouped rounded-table (Go's `-o pretty` default). if (output.format === "json" || output.format === "stream-json") { yield* output.success("", values); return; } - yield* output.raw( - `${legacyAqua("supabase")} local development setup is running.\n\n`, - "stderr", - ); - // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty - // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the - // CLI-supplied, override-populated `names` — `--override-name` only ever - // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. - // Remap names from the already-resolved `state` (empty override map) so the - // rendered table matches Go exactly without leaking `--override-name` into - // pretty-mode output, and without a second (throwing) state resolution. - const pretty = legacyStatusValuesFromState(state, new Map()); - yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + yield* renderPretty(); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index e6cdc712df..4fa59d9860 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -660,6 +660,19 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("lets --output pretty win over --output-format json", () => { + // Explicit `-o pretty` is a complete Go format choice (root.ts:119-121, + // matching functions/list) and must render the table, not defer to the + // TS-only --output-format json/stream-json branch. + const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("flushes telemetry via ensuring even on failure", () => { const { layer, telemetry } = setup({ route: (args) => From e3602603dbe4e57a493805211158d3f7504edd6a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 11:59:51 +0100 Subject: [PATCH 27/81] test(cli): cover --exclude/--ignore-health-check merge semantics on native status Adds three status.integration.test.ts scenarios the develop-side proxy port (28b17f6f, superseded here by the native implementation) motivated but that weren't yet exercised natively: multiple --exclude entries in one invocation, --exclude merged with an auto-detected stopped service (status.go:116's `excluded := append(stopped, exclude...)`), and --ignore-health-check=true against an explicitly unhealthy db (pairs with the existing false-case test). --- .../status/status.integration.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 4fa59d9860..e92b848e83 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -252,6 +252,25 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "succeeds against an unhealthy db when --ignore-health-check is set (status.go:104-108)", + () => { + // Pairs with "fails when the db container is unhealthy" below (ignoreHealthCheck: false, + // the default) to cover both sides of Go's `if !ignoreHealthCheck { assertContainerHealthy }`. + const { layer, child } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ Status: "running", Health: { Status: "starting" } }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect( + child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect"), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("reports stopped services on stderr", () => { const { layer, out } = setup({ route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), @@ -578,6 +597,38 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("omits every service named across multiple --exclude entries", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [authId, storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("merges an auto-detected stopped service with a --exclude entry (status.go:116)", () => { + // Go's `excluded := append(stopped, exclude...)` merges the health-derived + // stopped list with the user-supplied --exclude list — both must take effect + // together, not just whichever one the command would have applied alone. + const { layer, out } = setup({ + goOutput: Option.some("json"), + // kong (index 0) is absent from the running set, so it's auto-detected as stopped. + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + yield* legacyStatus(flags({ exclude: [authId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude + expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating + }).pipe(Effect.provide(layer)); + }); + it.live("outputs yaml with -o yaml", () => { const { layer, out } = setup({ goOutput: Option.some("yaml") }); return Effect.gen(function* () { From e8fdbbb79c53d0bc64684e486b41bd37e75aa9ad Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 12:10:54 +0100 Subject: [PATCH 28/81] fix(cli): match godotenv's quoted-value and colon-separator parsing in extra dotenv files (review: #5765) readDotEnvFile (used to load the project-root/SUPABASE_ENV-selected dotenv files stop/status need beyond loadProjectEnvironment) diverged from godotenv v1.5.1 in two ways: - Quote detection required the whole trimmed remainder to end with the opening quote, so `KEY="value" # comment` fell through to the unquoted branch and kept the literal quote characters. godotenv's extractVarValue (parser.go:160-180) instead scans forward for the first unescaped closing quote and discards anything after it as a comment. - The key/value separator only accepted `=`, rejecting godotenv's YAML-style `KEY: VALUE` form (parser.go:90-95's locateKeyName treats `=` and `:` as interchangeable), which packages/config/src/project.ts's parseDotEnv already accepts. Both fixes bring readDotEnvFile in line with godotenv and with the repo's other dotenv reader. --- .../shared/legacy-project-environment.ts | 35 +++++++++++++++---- .../legacy-project-environment.unit.test.ts | 19 ++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index 8cdf14d6ff..47d491b540 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -62,10 +62,15 @@ function expandDotEnvVariable(value: string, values: Readonly | undefined { const line = rawLine.trim(); if (line === "" || line.startsWith("#")) continue; - const match = /^(?:export\s+)?([\w.-]+)\s*=(.*)$/.exec(line); + const match = /^(?:export\s+)?([\w.-]+)\s*(?:=|:\s+)(.*)$/.exec(line); if (match === null) { throw new Error(`failed to parse environment file: ${path} (line ${index + 1})`); } @@ -89,8 +94,24 @@ function readDotEnvFile(path: string): Record | undefined { let value = (match[2] ?? "").trim(); const quote = value[0]; - if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) { - value = value.slice(1, -1); + // A value is quoted iff it STARTS with a quote — matching godotenv's + // `extractVarValue` (`joho/godotenv@v1.5.1/parser.go:160-180`), which locates the + // quoted span by scanning forward for the first unescaped matching quote, not by + // requiring the whole (trimmed) remainder to end with one. Anything after that + // closing quote (e.g. a trailing `# comment`) is discarded, so `"demo" # local` + // parses as `demo`, not the literal `"demo"` a naive `endsWith(quote)` check would + // produce by falling through to the unquoted branch below. + let quoteEnd = -1; + if (quote === '"' || quote === "'") { + for (let i = 1; i < value.length; i++) { + if (value[i] === quote && value[i - 1] !== "\\") { + quoteEnd = i; + break; + } + } + } + if (quoteEnd !== -1) { + value = value.slice(1, quoteEnd); if (quote === '"') { value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); value = expandDotEnvVariable(value, values); diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index c9e81a3c72..4502c01b0c 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -133,6 +133,25 @@ describe("legacyResolveProjectEnvironmentValues", () => { expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("54323"); }); + it("strips a trailing comment after a quoted value, matching godotenv", () => { + // godotenv's `extractVarValue` locates the quoted span by scanning forward for the + // closing quote (`godotenv@v1.5.1/parser.go:160-180`) and discards anything after + // it as a comment — the value is `demo`, not the literal `"demo"` a check that + // requires the whole trimmed remainder to end with a quote would produce. + writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { + // godotenv's `locateKeyName` treats `=` and `:` as interchangeable separators + // (`godotenv@v1.5.1/parser.go:90-95`), and the repo's other dotenv parser + // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("colon-project"); + }); + it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so // a key it resolved from a plain supabase/.env file is NOT necessarily From e865fcce230708656e72890ceef32adc825b4ee5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 12:13:12 +0100 Subject: [PATCH 29/81] fix(config): preserve env() literal when the referenced var is set but empty (review: #5765) Go's LoadEnvHook only substitutes an env(VAR) reference when the variable is non-empty (apps/cli-go/pkg/config/decode_hooks.go:19-24, `len(env) > 0`); a set-but-empty var (e.g. a dotenv `KEY=` line) leaves the literal `env(KEY)` string untouched, same as an unset var. substituteEnvLeaf only checked whether the key existed in the merged env map, so a present-but-empty value (which both project.ts's parseDotEnv and legacy-project-environment.ts's readDotEnvFile store verbatim as "") was substituted in as an empty string instead of preserving the literal. This mattered for stop/status: an empty project-root/SUPABASE_ENV-selected dotenv entry referenced via config.toml's `project_id = "env(...)"` would resolve to a different id than the Go-started stack used, potentially leaving containers running after `stop`. Fixed at the shared substitution point so both stop.handler.ts and status.handler.ts (and any other loadProjectConfig caller) get the fix for free, without touching the already-correct non-empty gate on the direct SUPABASE_PROJECT_ID/SUPABASE_AUTH_* overrides in legacy-docker-ids.ts. --- packages/config/src/io.unit.test.ts | 22 ++++++++++++++++++++++ packages/config/src/lib/env.ts | 16 ++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index d22d4eaf85..c4272f1654 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -758,6 +758,28 @@ jwt_secret = "env(MISSING_SECRET)" } }); + test("preserves env() literals on string fields when the var is set but empty (Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(MISSING_SECRET)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "MISSING_SECRET=\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("fails to decode a numeric field when env var is unset", async () => { const cwd = makeTempProject(); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index bd16819293..8c9c46756f 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -169,10 +169,15 @@ function substituteEnvLeaf(value: string, env: Readonly>) return value; } const envName = match[1]; - if (envName === undefined || !Object.prototype.hasOwnProperty.call(env, envName)) { + const resolved = envName === undefined ? undefined : env[envName]; + // Go's LoadEnvHook only substitutes when the env var is non-empty + // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a + // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the + // `env(KEY)` literal exactly like an unset key, rather than substituting "". + if (resolved === undefined || resolved === "") { return value; } - return env[envName] ?? value; + return resolved; } function isDeferredEnvField(ast: SchemaAST.AST): boolean { @@ -242,8 +247,11 @@ function walk( * * Walks the raw parsed document and the schema AST in parallel. For every * string leaf matching `env(VAR)`: - * 1. Substitutes `env[VAR]` if set, else preserves the literal verbatim - * (Go-parity with `apps/cli-go/pkg/config/decode_hooks.go:14-21`). + * 1. Substitutes `env[VAR]` if set AND non-empty, else preserves the + * literal verbatim (Go-parity with + * `apps/cli-go/pkg/config/decode_hooks.go:14-21`, which gates on + * `len(env) > 0` — a set-but-empty var, e.g. a dotenv `KEY=` line, + * leaves the `env(KEY)` literal untouched just like an unset one). * 2. If the schema at that path expects Number or Boolean, coerces the * substituted string to the expected primitive — mirroring Go's * mapstructure chain where `LoadEnvHook` returns a string that the next From de74ef1d0c2e73a69cebc9dc22ce8eeebb6b4d5c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 12:16:53 +0100 Subject: [PATCH 30/81] fix(cli): apply SUPABASE_* env overrides to db/studio/mailpit/api ports in status (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load binds Viper generically across the whole config struct (SetEnvPrefix("SUPABASE") + AutomaticEnv() + a "."->"_" key replacer, pkg/config/config.go:529-535) — not just auth fields. config_test.go:351,1061 exercise this against a non-auth field (auth.site_url with no env(...) reference needed), and internal/status/status.go's toValues() reads the already-overridden utils.Config.* directly, so status parity there is automatic on the Go side. legacy-local-config-values.ts already had the right mechanism (envOverride) but only wired it up for the 6 auth fields; db.port, studio.port, local_smtp.port, and api.port/external_url were read raw, so a stack started with e.g. SUPABASE_DB_PORT set would make `status` print a port that didn't match the actually-running container. Extended envOverride to SUPABASE_DB_PORT, SUPABASE_STUDIO_PORT, SUPABASE_LOCAL_SMTP_PORT, SUPABASE_API_PORT, and SUPABASE_API_EXTERNAL_URL. Left api.tls.enabled (scheme selection) out of this pass since it needs its own bool-coercion convention. --- .../shared/legacy-local-config-values.ts | 45 ++++++++++--- .../legacy-local-config-values.unit.test.ts | 63 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index dd44153565..c32a11aeb2 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -91,11 +91,15 @@ const MIN_JWT_SECRET_LENGTH = 16; /** * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), - * so any config field can be overridden by a `SUPABASE_` env var — - * this resolves it for exactly the 6 auth fields this module reads, at the - * same higher-than-config.toml precedence Viper gives env vars. An empty env - * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never - * enabled in `config.go`). + * so ANY config field can be overridden by a `SUPABASE_` env var, + * generically across the whole struct — not just auth fields + * (`config_test.go:351,1061` exercise this against `auth.site_url`, and + * `internal/status/status.go:52-95`'s `toValues()` reads `utils.Config.*` + * directly, so every already-overridden field is automatically reflected in + * `status`'s output). This resolves it for every field this module derives a + * URL/port from, at the same higher-than-config.toml precedence Viper gives + * env vars. An empty env var is treated as unset, matching Viper's default + * (`AllowEmptyEnv` is never enabled in `config.go`). * * Viper's `AutomaticEnv` binding runs AFTER `Config.Load`'s `loadNestedEnv` * (`config.go:735-738`), which loads `supabase/.env`(.local) and project-root @@ -217,7 +221,30 @@ export function legacyResolveLocalConfigValues( workdir: string, projectEnvValues: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { - const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); + // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl` after Viper's + // AutomaticEnv has already applied any `SUPABASE_API_PORT`/ + // `SUPABASE_API_EXTERNAL_URL` override (`config.go:529-535`), so the port + // fed into `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- + // `scheme://host:port` derivation must be the overridden one too. + const apiExternalUrl = legacyResolveApiExternalUrl( + { + external_url: envOverride( + "SUPABASE_API_EXTERNAL_URL", + config.api.external_url, + projectEnvValues, + ), + port: Number(envOverride("SUPABASE_API_PORT", String(config.api.port), projectEnvValues)), + tls: config.api.tls, + }, + hostname, + ); + const dbPort = Number(envOverride("SUPABASE_DB_PORT", String(config.db.port), projectEnvValues)); + const studioPort = Number( + envOverride("SUPABASE_STUDIO_PORT", String(config.studio.port), projectEnvValues), + ); + const mailpitPort = Number( + envOverride("SUPABASE_LOCAL_SMTP_PORT", String(config.local_smtp.port), projectEnvValues), + ); const jwtSecret = resolveJwtSecret( envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), ); @@ -243,9 +270,9 @@ export function legacyResolveLocalConfigValues( graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), - studioUrl: `http://${hostname}:${config.studio.port}`, - mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, - dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, + studioUrl: `http://${hostname}:${studioPort}`, + mailpitUrl: `http://${hostname}:${mailpitPort}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, publishableKey: resolveOpaqueKey( envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues), defaultPublishableKey, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 6fdf15c576..33b06240db 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -232,6 +232,69 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("non-auth SUPABASE_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // generically across the whole config struct (pkg/config/config.go:529-535), + // not just auth fields — config_test.go:351,1061 exercise this against + // auth.site_url, and status.go's toValues() reads the already-overridden + // utils.Config.* directly, so every port/URL status derives must honor the + // same override. + const ENV_KEYS = [ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + "SUPABASE_API_EXTERNAL_URL", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides db.port for the derived DB URL", () => { + process.env["SUPABASE_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("overrides studio.port for the derived Studio URL", () => { + process.env["SUPABASE_STUDIO_PORT"] = "54330"; + const config = baseConfig({ studio: { port: 54323 } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ local_smtp: { port: 54324 } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ api: { port: 54321 } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ api: { external_url: "https://config.example" } }); + const values = legacyResolveLocalConfigValues(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"] = ""; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + }); + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); From 3242e54481a386b106a74277b462dd77d6f1ad63 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 13:05:25 +0100 Subject: [PATCH 31/81] fix(cli): read stop/status dotenv overrides even when config.toml is absent (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's loadNestedEnv (pkg/config/config.go:786-793) runs unconditionally before config.toml is ever opened, so supabase/.env(.local) and project-root dotenv files are always consulted for SUPABASE_PROJECT_ID and SUPABASE_AUTH_* overrides — even when there's no config.toml at all. legacyResolveProjectEnvironmentValues previously gave up entirely in that case (projectEnv === null), silently falling back to ambient env only. It now derives /supabase and workdir directly and still reads their dotenv files, matching Go. --- .../legacy/commands/status/status.handler.ts | 15 ++- .../status/status.integration.test.ts | 21 +++ .../src/legacy/commands/stop/stop.handler.ts | 26 ++-- .../commands/stop/stop.integration.test.ts | 21 +++ .../shared/legacy-project-environment.ts | 39 ++++-- .../legacy-project-environment.unit.test.ts | 123 +++++++++++------- 6 files changed, 172 insertions(+), 73 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index da7f37db81..7d7bcc7dcb 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -124,18 +124,19 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `SUPABASE_PROJECT_ID`/`SUPABASE_AUTH_*` overrides read further below. // A malformed extra dotenv file throws here (see `readDotEnvFile`), // matching Go's `loadNestedEnv` propagating `godotenv`'s parse error - // instead of silently skipping the bad line. + // instead of silently skipping the bad line. `workdir` is passed through so + // dotenv files under `/supabase`/`workdir` are still discovered + // even when `projectEnv` is `null` (no config.toml there) — Go's own + // `loadNestedEnv` runs unconditionally, before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`). const projectEnvValues = yield* Effect.try({ - try: () => legacyResolveProjectEnvironmentValues(projectEnv), + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), catch: (cause) => new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), }); const loaded = yield* loadProjectConfig(cliConfig.workdir, { - projectEnv: - projectEnv !== null - ? { ...projectEnv, values: projectEnvValues ?? projectEnv.values } - : undefined, + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, }).pipe( Effect.mapError( (cause) => @@ -153,7 +154,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `legacyCliProjectFilterValue`'s doc comment). const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], config.project_id, cliConfig.workdir, ), diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index e92b848e83..ae5783dba1 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -436,6 +436,27 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); + const { layer, child } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds("no-config-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("no-config-project")); + }).pipe(Effect.provide(layer)); + }); + it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) // before AutomaticEnv reads SUPABASE_AUTH_JWT_SECRET (config.go:735-738) — diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 3a1428cee3..4eb1c24d65 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -49,11 +49,12 @@ import { * `legacyResolveProjectEnvironmentValues` implements that full precedence * chain (see its doc comment) on top of `loadProjectEnvironment`'s * `supabase/`-dir-only result, so it's used here instead of reading - * `process.env` directly. Both resolve to `undefined`/`null` when no - * `supabase/` config file exists anywhere up the tree, which would otherwise - * also drop the ambient-only case — falling back to `process.env` directly - * covers that gap without duplicating the "no config.toml" path's own error - * handling. + * `process.env` directly. It still returns a usable map (falling back to + * `/supabase`/`workdir` and `process.env` itself) even when no + * `supabase/` config file exists at `workdir`, matching Go's `loadNestedEnv` + * running unconditionally before `config.toml` is ever opened + * (`pkg/config/config.go:786-793`) — the `?? process.env[...]` fallback below + * only still matters for keys neither source produced. * * The config/env-derived (default) branch is sanitized with * {@link legacySanitizeProjectId} before it's used as a filter value, @@ -95,9 +96,13 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject // `SUPABASE_PROJECT_ID` override read below. A malformed extra dotenv // file throws here (see `readDotEnvFile`), matching Go's `loadNestedEnv` // propagating `godotenv`'s parse error instead of silently skipping the - // bad line. + // bad line. `workdir` is passed through so dotenv files under + // `/supabase`/`workdir` are still discovered even when + // `projectEnv` is `null` (no config.toml there) — Go's own `loadNestedEnv` + // runs unconditionally, before `config.toml` is ever opened + // (`pkg/config/config.go:786-793`). const projectEnvValues = yield* Effect.try({ - try: () => legacyResolveProjectEnvironmentValues(projectEnv), + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), catch: (cause) => new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), }); @@ -107,10 +112,7 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject // malformed file (`loadProjectConfig` failing rather than returning // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. const loaded = yield* loadProjectConfig(cliConfig.workdir, { - projectEnv: - projectEnv !== null - ? { ...projectEnv, values: projectEnvValues ?? projectEnv.values } - : undefined, + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, }).pipe( Effect.mapError( (cause) => @@ -118,7 +120,7 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject ), ); const resolved = legacyResolveLocalProjectId( - projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index fbf4668f59..adba6ce533 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -401,6 +401,27 @@ describe("legacy stop integration", () => { ); }); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=no-config-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=no-config-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { // Go's loadNestedEnv walks past supabase/ one more level, to the project // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index 47d491b540..8a10cbc4c7 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -146,24 +146,35 @@ function readDotEnvFile(path: string): Record | undefined { * layered back on top (`projectEnv.sources[key] === "ambient"` marks exactly * those entries — see `loadProjectEnvironment`'s `ProjectEnvironment` shape). * - * Returns `undefined` when `projectEnv` is `null` (no `supabase/` project - * found), matching callers' existing "fall back to `process.env` directly" - * behavior. + * `projectEnv` is `null` whenever `@supabase/config` found no + * `supabase/config.toml`/`config.json` (searching ancestors, or at exactly + * `workdir` when the caller passed `search: false`) — but Go's dotenv loading + * doesn't share that precondition: `Config.Load` calls + * `loadNestedEnv(builder.SupabaseDirPath)` BEFORE it ever opens `config.toml` + * (`pkg/config/config.go:786-793`), and `SupabaseDirPath` is a pure string + * join with no existence check (`NewPathBuilder`, `pkg/config/utils.go:43-48`). + * So a missing/absent config file must not skip dotenv loading — fall back to + * deriving the same two directories directly from `workdir` + * (`/supabase` and `workdir` itself) and read `process.env` itself as + * the ambient layer, since there's no `loadProjectEnvironment` result to + * consult for it in this branch. */ export function legacyResolveProjectEnvironmentValues( projectEnv: ProjectEnvironment | null, -): Record | undefined { - if (projectEnv === null) return undefined; - + workdir: string, +): Record { const env = process.env["SUPABASE_ENV"] || "development"; const filenames = candidateDotenvFilenames(env); const merged: Record = {}; + const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); + const projectRoot = projectEnv?.paths.projectRoot ?? workdir; + // supabase/ dir first, then its parent (the project root) — matching Go's // directory walk order. Within a directory, `godotenv.Load`'s "never // override an already-set var" means first-processed-wins, so the plain // merge below (skip keys already present) reproduces both orderings at once. - for (const dir of [projectEnv.paths.supabaseDir, projectEnv.paths.projectRoot]) { + for (const dir of [supabaseDir, projectRoot]) { for (const filename of filenames) { const parsed = readDotEnvFile(join(dir, filename)); if (parsed === undefined) continue; @@ -174,9 +185,17 @@ export function legacyResolveProjectEnvironmentValues( } const ambientOverrides: Record = {}; - for (const [key, value] of Object.entries(projectEnv.values)) { - if (projectEnv.sources[key] === "ambient") { - ambientOverrides[key] = value; + if (projectEnv !== null) { + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } + } + } else { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + ambientOverrides[key] = value; + } } } diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index 4502c01b0c..5e9a6cdc59 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { afterEach(() => { rmSync(root, { recursive: true, force: true }); delete process.env["SUPABASE_ENV"]; + delete process.env["SUPABASE_PROJECT_ID"]; }); function fakeProjectEnv( @@ -43,28 +44,24 @@ function fakeProjectEnv( } describe("legacyResolveProjectEnvironmentValues", () => { - it("returns undefined when no project was found", () => { - expect(legacyResolveProjectEnvironmentValues(null)).toBeUndefined(); - }); - it("returns just the already-loaded values when no extra dotenv files exist", () => { const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); - expect(legacyResolveProjectEnvironmentValues(projectEnv)).toEqual({ + expect(legacyResolveProjectEnvironmentValues(projectEnv, root)).toEqual({ SUPABASE_PROJECT_ID: "from-loader", }); }); it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); }); it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); }); it("lets already-resolved projectEnv.values win over anything discovered locally", () => { @@ -73,64 +70,64 @@ describe("legacyResolveProjectEnvironmentValues", () => { // entry for the same key must never override it. writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); - const merged = legacyResolveProjectEnvironmentValues(projectEnv); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); }); it("defaults SUPABASE_ENV to development when unset", () => { writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); }); it("selects the SUPABASE_ENV-named file over the bare .env file", () => { process.env["SUPABASE_ENV"] = "production"; writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); }); it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { process.env["SUPABASE_ENV"] = "production"; writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); }); it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { process.env["SUPABASE_ENV"] = "test"; writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("test-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); }); it("strips quotes the same way the shared dotenv parser does", () => { writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); }); it("ignores blank lines and comments", () => { writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); }); it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { // godotenv only starts an inline comment at a `#` preceded by whitespace // (`godotenv@v1.5.1/parser.go:144-153`); `foo#bar` keeps the `#` verbatim. writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); }); it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("54323"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); }); it("strips a trailing comment after a quoted value, matching godotenv", () => { @@ -139,8 +136,8 @@ describe("legacyResolveProjectEnvironmentValues", () => { // it as a comment — the value is `demo`, not the literal `"demo"` a check that // requires the whole trimmed remainder to end with a quote would produce. writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("demo"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); }); it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { @@ -148,8 +145,8 @@ describe("legacyResolveProjectEnvironmentValues", () => { // (`godotenv@v1.5.1/parser.go:90-95`), and the repo's other dotenv parser // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("colon-project"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); }); it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { @@ -166,8 +163,8 @@ describe("legacyResolveProjectEnvironmentValues", () => { { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, { SUPABASE_PROJECT_ID: ".env" }, ); - const merged = legacyResolveProjectEnvironmentValues(projectEnv); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); }); it("still lets a truly ambient-sourced value win over any file", () => { @@ -180,13 +177,13 @@ describe("legacyResolveProjectEnvironmentValues", () => { { SUPABASE_PROJECT_ID: "ambient-project" }, { SUPABASE_PROJECT_ID: "ambient" }, ); - const merged = legacyResolveProjectEnvironmentValues(projectEnv); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); }); it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { writeFileSync(join(root, ".env"), "not a valid line\n"); - expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv())).toThrow( + expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root)).toThrow( /failed to parse environment file/, ); }); @@ -195,27 +192,65 @@ describe("legacyResolveProjectEnvironmentValues", () => { // godotenv expands unquoted/double-quoted references while loading // (`godotenv@v1.5.1/parser.go:157`), so a later key can reuse an earlier one. writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("demo"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); }); it("expands a braced ${VAR} reference in a double-quoted value", () => { writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); }); it("does not expand variable references inside single-quoted values", () => { // godotenv never calls expandVariables for single-quoted values // (`parser.go:172-173`) — they stay byte-literal. writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); }); it("expands an unresolved reference to an empty string, matching Go's map zero-value", () => { writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); - expect(merged?.["SUPABASE_PROJECT_ID"]).toBe(""); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); + }); + + describe("when no project was found (projectEnv is null)", () => { + // Go's `loadNestedEnv` runs unconditionally before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`), so a missing config file must + // not skip dotenv loading — these cover the local fallback that derives + // `/supabase`/`workdir` directly instead of giving up. + + it("still reads a supabase/-dir dotenv file directly under workdir", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); + }); + + it("still reads a project-root dotenv file directly under workdir", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); + }); + + it("prefers the supabase/-dir file over the project-root file, same as the non-null case", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets an ambient shell var win over a dotenv value, using process.env directly", () => { + process.env["SUPABASE_PROJECT_ID"] = "ambient-fallback-project"; + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=dotenv-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); + }); + + it("returns an empty object when workdir has no dotenv files and no ambient value", () => { + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); + }); }); }); From ae68ba519da419081ee1dc696de3bfd48a75d605 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 13:07:39 +0100 Subject: [PATCH 32/81] fix(cli): treat --workdir as an exact, non-searching project root in stop/status (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's ChangeWorkDir resolves --workdir/SUPABASE_WORKDIR to an exact directory (no ancestor climb once explicit; ancestor search via getProjectRoot only as the *default* when unset, apps/cli-go/internal/utils/misc.go:231-247) and then reads supabase/config.toml as a plain relative path with no further search (pkg/config/utils.go:43-48). cliConfig.workdir already mirrors that resolution, but handing it to @supabase/config's loadProjectEnvironment/ loadProjectConfig let findProjectPaths climb ancestors a second time — so a --workdir pointing at a subdirectory with no config.toml of its own could pick up an unrelated ancestor project's config instead of falling back to defaults. Adds an opt-in `search: false` option to findProjectPaths (and thus loadProjectEnvironment/loadProjectConfig) that checks only the given cwd, defaulting to the existing ancestor-search behavior for every other caller, and wires it into stop/status. --- .../legacy/commands/status/status.handler.ts | 12 ++++++ .../status/status.integration.test.ts | 33 +++++++++++++++- .../src/legacy/commands/stop/stop.handler.ts | 12 ++++++ .../commands/stop/stop.integration.test.ts | 39 ++++++++++++++++++- packages/config/src/io.ts | 5 ++- packages/config/src/paths.ts | 35 ++++++++++++++++- packages/config/src/project.ts | 4 +- packages/config/src/project.unit.test.ts | 35 +++++++++++++++++ 8 files changed, 168 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 7d7bcc7dcb..0f5d25ab47 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -102,9 +102,20 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // Mirror that by decoding an empty document through the schema for its // defaults (matching `packages/config/src/functions-manifest.ts`'s // `decodeProjectConfig({})` pattern) instead of failing. + // `search: false` on both loaders below: `cliConfig.workdir` already IS + // Go's fully-resolved chdir target (`legacy-cli-config.layer.ts`'s + // `resolveWorkdir` mirrors `ChangeWorkDir`'s explicit-exact-vs-default- + // searched resolution, `apps/cli-go/internal/utils/misc.go:231-247`), so + // letting `@supabase/config`'s `findProjectPaths` climb ancestors again on + // top of that would let an unrelated ancestor project's config.toml win + // when `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). const projectEnv = yield* loadProjectEnvironment({ cwd: cliConfig.workdir, baseEnv: process.env, + search: false, }).pipe( Effect.mapError( (cause) => @@ -137,6 +148,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const loaded = yield* loadProjectConfig(cliConfig.workdir, { projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, }).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index ae5783dba1..e92fd69312 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -168,10 +168,12 @@ interface SetupOpts { readonly failSpawnFor?: (args: ReadonlyArray) => boolean; readonly skipConfig?: boolean; readonly configContents?: string; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; } function setup(opts: SetupOpts = {}) { - const workdir = tempRoot.current; + const workdir = opts.workdir ?? tempRoot.current; if (opts.skipConfig !== true) { writeConfig(workdir, opts.configContents); } @@ -436,6 +438,35 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId(projectId)); + expect(inspectCall?.args).not.toContain(localDbContainerId("ancestor-project")); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { // Go's loadNestedEnv runs unconditionally, before config.toml is ever // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 4eb1c24d65..57711dc93d 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -78,9 +78,20 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject return flags.projectId.value; } + // `search: false`: `cliConfig.workdir` already IS Go's fully-resolved chdir + // target (`legacy-cli-config.layer.ts`'s `resolveWorkdir` mirrors + // `ChangeWorkDir`'s explicit-exact-vs-default-searched resolution, + // `apps/cli-go/internal/utils/misc.go:231-247`), so letting + // `@supabase/config`'s `findProjectPaths` climb ancestors again on top of + // that would let an unrelated ancestor project's config.toml win when + // `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). const projectEnv = yield* loadProjectEnvironment({ cwd: cliConfig.workdir, baseEnv: process.env, + search: false, }).pipe( Effect.mapError( (cause) => @@ -113,6 +124,7 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. const loaded = yield* loadProjectConfig(cliConfig.workdir, { projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, }).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index adba6ce533..cc194bd087 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -171,10 +171,12 @@ interface SetupOpts { readonly failSpawnFor?: (args: ReadonlyArray) => boolean; readonly configuredProjectId?: string; readonly skipConfig?: boolean; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; } function setup(opts: SetupOpts = {}) { - const workdir = tempRoot.current; + const workdir = opts.workdir ?? tempRoot.current; if (opts.skipConfig !== true) { writeConfig(workdir, opts.configuredProjectId ?? "demo"); } @@ -401,6 +403,39 @@ describe("legacy stop integration", () => { ); }); + it.live( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, "ancestor-project"); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { // Go's loadNestedEnv runs unconditionally, before config.toml is ever // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 85f9ca6201..005527645f 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -51,6 +51,8 @@ export interface LoadProjectConfigOptions { * so loading does not re-read those files or depend on `process.env` mutation. */ readonly projectEnv?: ProjectEnvironment; + /** See {@link FindProjectPathsOptions.search}. */ + readonly search?: boolean; } export interface SaveProjectConfigOptions { @@ -405,6 +407,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( (yield* loadProjectEnvironment({ cwd: projectRoot, baseEnv: process.env, + search: options?.search, })); const interpolated = interpolateEnvReferencesAgainstSchema( normalized, @@ -441,7 +444,7 @@ export const loadProjectConfig = Effect.fnUntraced(function* ( options?: LoadProjectConfigOptions, ) { const fs = yield* FileSystem.FileSystem; - const project = yield* findProjectPaths(cwd); + const project = yield* findProjectPaths(cwd, { search: options?.search }); if (project === null) { return null; diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index e41017793d..bf29f44dcc 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -31,10 +31,41 @@ const findConfigInRoot = Effect.fnUntraced(function* (root: string) { } satisfies ProjectPaths; }); -export const findProjectPaths = Effect.fnUntraced(function* (cwd: string) { +export interface FindProjectPathsOptions { + /** + * When `false`, only `cwd` itself is checked for `supabase/config.{json,toml}` — + * no ancestor climb. Go's own resolution never searches twice: an explicit + * `--workdir`/`SUPABASE_WORKDIR` is used exactly as given (`ChangeWorkDir`, + * `apps/cli-go/internal/utils/misc.go:231-247`), and once `os.Chdir`'d there, + * `config.toml` is read as a plain relative path with no further ancestor + * search (`NewPathBuilder`, `pkg/config/utils.go:43-48`). Ancestor climbing in + * Go only ever happens once, as the *default* when workdir is unset + * (`getProjectRoot`, `internal/utils/misc.go:209-224`). + * + * Callers that already hold an authoritative, Go-equivalent project root + * (e.g. the legacy `stop`/`status` ports' `cliConfig.workdir`, which mirrors + * `ChangeWorkDir`'s own explicit-vs-default resolution) should pass `false` + * here to avoid a second, un-Go-like ancestor search that could otherwise + * pick up an unrelated ancestor project's config. + * + * Defaults to `true` (the original ancestor-search behavior), so existing + * callers are unaffected. + */ + readonly search?: boolean; +} + +export const findProjectPaths = Effect.fnUntraced(function* ( + cwd: string, + options?: FindProjectPathsOptions, +) { const path = yield* Path.Path; - let current = path.resolve(cwd); + const start = path.resolve(cwd); + + if (options?.search === false) { + return yield* findConfigInRoot(start); + } + let current = start; while (true) { const match = yield* findConfigInRoot(current); diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 68953f2b1b..2bd5574925 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -113,13 +113,15 @@ function applySource( export interface LoadProjectEnvironmentOptions { readonly cwd: string; readonly baseEnv?: Readonly>; + /** See {@link FindProjectPathsOptions.search}. */ + readonly search?: boolean; } export const loadProjectEnvironment = Effect.fnUntraced(function* ( options: LoadProjectEnvironmentOptions, ) { const fs = yield* FileSystem.FileSystem; - const paths = yield* findProjectPaths(options.cwd); + const paths = yield* findProjectPaths(options.cwd, { search: options.search }); if (paths === null) { return null; diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index de22a465fe..e01b0972b1 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -50,6 +50,41 @@ describe("project discovery and lazy env resolution", () => { } }); + test("search: false only checks cwd itself, matching Go's exact-workdir resolution", async () => { + // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-247`): + // an explicit workdir is used exactly as given, with no ancestor climb — + // callers that already hold a Go-equivalent project root (e.g. the legacy + // `stop`/`status` ports' `cliConfig.workdir`) pass `search: false` to avoid + // picking up an unrelated ancestor project. + const cwd = makeTempProject(); + const repoRoot = join(cwd, "repo"); + const packageRoot = join(repoRoot, "apps", "web"); + const nestedCwd = join(packageRoot, "src", "components"); + + try { + await mkdir(join(repoRoot, "supabase"), { recursive: true }); + await mkdir(nestedCwd, { recursive: true }); + await writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'); + + // nestedCwd has no supabase/ of its own; only an ancestor (repoRoot) does. + const searched = await runConfigEffect(findProjectPaths(nestedCwd)); + expect(searched?.projectRoot).toBe(repoRoot); + + const unsearched = await runConfigEffect(findProjectPaths(nestedCwd, { search: false })); + expect(unsearched).toBeNull(); + + const configAtRepoRoot = await runConfigEffect(findProjectPaths(repoRoot, { search: false })); + expect(configAtRepoRoot?.projectRoot).toBe(repoRoot); + + expect(await runConfigEffect(loadProjectConfig(nestedCwd, { search: false }))).toBeNull(); + expect( + await runConfigEffect(loadProjectEnvironment({ cwd: nestedCwd, search: false })), + ).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads env from the discovered supabase directory with the right precedence", async () => { const cwd = makeTempProject(); const repoRoot = join(cwd, "repo"); From e2a278cda57df8b6087e2b427146aa44d74d34bd Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 13:09:33 +0100 Subject: [PATCH 33/81] fix(cli): apply SUPABASE_API_TLS_ENABLED to status/stop URL scheme (review: #5765) Go applies the Viper-bound api.tls.enabled override (config.go:582-586) before deriving the default api.external_url scheme (config.go:799-809), so an ambient/dotenv SUPABASE_API_TLS_ENABLED=true flips status/stop's reported endpoints to https:// even when config.toml itself has api.tls.enabled = false (and vice versa). legacyResolveLocalConfigValues previously passed the raw TOML tls value through unchanged. Adds envOverrideBool, a boolean-flavored sibling of the existing envOverride helper that decodes the override with Go's strconv.ParseBool acceptance set (legacyParseGoBool, now exported from legacy-db-config.toml-read.ts), and wires it into the tls.enabled field. --- .../shared/legacy-db-config.toml-read.ts | 6 ++- .../shared/legacy-local-config-values.ts | 44 +++++++++++++++--- .../legacy-local-config-values.unit.test.ts | 45 +++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 0e4bc28028..9664bca093 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -619,8 +619,12 @@ const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly * typed decode). Returns the bool, or `undefined` for a malformed value (which Go * surfaces as a `failed to parse config` error). + * + * Exported for reuse by other `legacy/shared/` bool-flavored `SUPABASE_*` env + * overrides (e.g. `legacy-local-config-values.ts`'s `api.tls.enabled`/ + * `auth.enabled`) that need the same `strconv.ParseBool` acceptance set. */ -function legacyParseGoBool(value: string): boolean | undefined { +export function legacyParseGoBool(value: string): boolean | undefined { if (GO_BOOL_TRUE.has(value)) return true; if (GO_BOOL_FALSE.has(value)) return false; return undefined; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index c32a11aeb2..ac8a9cadb3 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -6,6 +6,7 @@ import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supa import { Schema } from "effect"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { legacyParseGoBool } from "./legacy-db-config.toml-read.ts"; import { legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, @@ -119,6 +120,29 @@ function envOverride( return value !== undefined && value.length > 0 ? value : configured; } +/** + * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields Go + * decodes as a native bool (`api.tls.enabled`, `auth.enabled`) rather than a + * string/number — those are bound by the same generic Viper mechanism + * (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()`, + * `pkg/config/config.go:582-586`), but the override string must be decoded + * with Go's own `strconv.ParseBool` acceptance set ({@link legacyParseGoBool}) + * instead of used verbatim. A malformed override (not one of Go's accepted + * bool spellings) falls back to `configured` — matching this module's + * existing leniency for other env-derived fields (e.g. `Number(envOverride(...))` + * on ports isn't hard-validated either) rather than hard-failing the whole + * `stop`/`status` run over a bad override. + */ +function envOverrideBool( + name: string, + configured: boolean, + projectEnvValues: Readonly> | undefined, +): boolean { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configured; + return legacyParseGoBool(value) ?? configured; +} + /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ function resolveJwtSecret(configured: string | undefined): string { if (configured === undefined || configured.length === 0) return defaultJwtSecret; @@ -221,11 +245,13 @@ export function legacyResolveLocalConfigValues( workdir: string, projectEnvValues: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { - // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl` after Viper's - // AutomaticEnv has already applied any `SUPABASE_API_PORT`/ - // `SUPABASE_API_EXTERNAL_URL` override (`config.go:529-535`), so the port - // fed into `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- - // `scheme://host:port` derivation must be the overridden one too. + // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` + // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ + // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override + // (`config.go:529-535,799-809`), so the values fed into + // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- + // `scheme://host:port` derivation (which picks `https` vs `http` from + // `tls.enabled`) must be the overridden ones too. const apiExternalUrl = legacyResolveApiExternalUrl( { external_url: envOverride( @@ -234,7 +260,13 @@ export function legacyResolveLocalConfigValues( projectEnvValues, ), port: Number(envOverride("SUPABASE_API_PORT", String(config.api.port), projectEnvValues)), - tls: config.api.tls, + tls: { + enabled: envOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + projectEnvValues, + ), + }, }, hostname, ); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 33b06240db..a1556fac84 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -295,6 +295,51 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("SUPABASE_API_TLS_ENABLED env override", () => { + // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) + // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), + // so an ambient/dotenv override flips http/https even when config.toml says + // otherwise. + afterEach(() => { + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("overrides api.tls.enabled from false to true", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ api: { external_url: "http://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://config.example"); + }); + + it("falls back to the configured value for a malformed override, matching this module's leniency", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("treats an empty override as unset, matching Viper's default", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = ""; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + }); + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); From aff9ff03bc512c5e1543bae9b358bcb9befba14b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 13:11:24 +0100 Subject: [PATCH 34/81] fix(cli): apply SUPABASE_AUTH_ENABLED before status/stop signing-key reads (review: #5765) Go's Config.Auth.Enabled is Viper-bound the same generic way as every other field (config.go:582-586), so Validate's `if c.Auth.Enabled` gate around the signing-keys file read (config.go:1036,1059-1065) reads the POST-SUPABASE_AUTH_ENABLED-override value, not raw TOML. The signingKey gate here still read config.auth.enabled directly, so a stale/rotated auth.signing_keys_path could make native status/stop fail even when auth is disabled only via env/dotenv. Reuses the envOverrideBool helper added for the SUPABASE_API_TLS_ENABLED fix to resolve the gate. --- .../shared/legacy-local-config-values.ts | 22 +++++++--- .../legacy-local-config-values.unit.test.ts | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index ac8a9cadb3..8b1d61db08 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -204,10 +204,12 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ * `JSON.parse`'s raw Node error text through unwrapped. * - * Callers must only invoke this when `config.auth.enabled` is true — Go's - * `Validate` nests the entire signing-keys read inside `if c.Auth.Enabled` - * (`pkg/config/config.go:1036,1059-1065`), so a disabled auth section never - * touches `signing_keys_path`, however stale or missing that file is. + * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- + * overridden value, not necessarily raw `config.auth.enabled` — see + * {@link envOverrideBool}) — Go's `Validate` nests the entire signing-keys read + * inside `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`), reading + * that same post-override value, so a disabled auth section never touches + * `signing_keys_path`, however stale or missing that file is. */ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { const absolutePath = isAbsolute(signingKeysPath) @@ -290,9 +292,17 @@ export function legacyResolveLocalConfigValues( // disabled auth section never opens/parses `signing_keys_path`, even a stale // or missing one. JWT-secret validation and anon/service_role key generation // (`generateAPIKeys`, `apikeys.go:43-73`) run unconditionally either way, so - // only this file read is gated. + // only this file read is gated. `c.Auth.Enabled` is itself Viper-bound like + // any other field (`config.go:582-586`), so `Validate`'s gate reads the + // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence + // `envOverrideBool` here instead of `config.auth.enabled` directly. + const authEnabled = envOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + projectEnvValues, + ); const signingKey = - config.auth.enabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index a1556fac84..84b06f7461 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -451,5 +451,48 @@ describe("legacyResolveLocalConfigValues", () => { iss: "supabase-demo", }); }); + + describe("SUPABASE_AUTH_ENABLED env override", () => { + // `c.Auth.Enabled` is Viper-bound like any other field + // (config.go:582-586), so `Validate`'s `if c.Auth.Enabled` gate + // (config.go:1036,1059-1065) reads the POST-override value, not raw + // TOML — a stale/missing signing_keys_path must be skipped when auth is + // disabled only via env/dotenv, and read when auth is enabled only via + // env/dotenv despite TOML saying otherwise. + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + }); + + it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ + auth: { enabled: true, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("falls back to the configured value for a malformed override", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + }); }); }); From 81811c0138ec1e2fef24591a64c885715714299c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:02:51 +0100 Subject: [PATCH 35/81] fix(cli): read Docker's Running boolean instead of deriving it from Status (review: #5765) Go's assertContainerHealthy (internal/status/status.go:150) gates on the boolean resp.State.Running, not the Status string, so a paused or restarting container (Running: true, Status: "paused"/"restarting") still passes through to the health check in Go. legacyInspectContainerState was deriving `running` from `status === "running"`, which would fail `status`/`stop` early for exactly that case instead of matching Go's behaviour. --- .../status/status.integration.test.ts | 44 +++++++++++++++++-- .../legacy/shared/legacy-docker-lifecycle.ts | 9 +++- .../legacy-docker-lifecycle.unit.test.ts | 26 +++++++++-- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index e92fd69312..56bbb7de73 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -132,7 +132,11 @@ function mockRoutedContainerCliSpawner( } const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); -const HEALTHY_DB_STATE = JSON.stringify({ Status: "running", Health: { Status: "healthy" } }); +const HEALTHY_DB_STATE = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, +}); /** * Default happy-path router: db container inspect reports healthy+running, `ps` @@ -261,7 +265,11 @@ describe("legacy status integration", () => { // the default) to cover both sides of Go's `if !ignoreHealthCheck { assertContainerHealthy }`. const { layer, child } = setup({ route: defaultRoute({ - dbInspectStdout: JSON.stringify({ Status: "running", Health: { Status: "starting" } }), + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), }), }); return Effect.gen(function* () { @@ -555,7 +563,9 @@ describe("legacy status integration", () => { it.live("fails when the db container is not running", () => { const { layer } = setup({ - route: defaultRoute({ dbInspectStdout: JSON.stringify({ Status: "exited" }) }), + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ Status: "exited", Running: false }), + }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); @@ -568,6 +578,28 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "succeeds against a paused-but-healthy db, matching Go's boolean-based running gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) gates on the boolean + // `resp.State.Running`, not the status string — a paused container can + // report `Running: true` alongside `Status: "paused"`, and Go continues + // past the not-running branch to the health check in that case. + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "paused", + Running: true, + Health: { Status: "healthy" }, + }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { // Go's `assertContainerHealthy` never special-cases "not found" — it wraps // whatever `ContainerInspect` returns (`status.go:148-149`), so the real @@ -594,7 +626,11 @@ describe("legacy status integration", () => { it.live("fails when the db container is unhealthy", () => { const { layer } = setup({ route: defaultRoute({ - dbInspectStdout: JSON.stringify({ Status: "running", Health: { Status: "starting" } }), + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), }), }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index 985410723f..f8884a700a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -182,8 +182,15 @@ function parseContainerState(stdout: string): { parsed = {}; } const state = isJsonRecord(parsed) ? parsed : {}; + // Go's `assertContainerHealthy` (`internal/status/status.go:147-156`) gates + // on the boolean `resp.State.Running`, not the status string — Docker's + // inspect `State` struct exposes both independently, and a paused or + // restarting container reports `Running: true` alongside a non-"running" + // `Status` (`"paused"`/`"restarting"`). `status` is kept as-is for the + // "container is not running: " message text (`status.go:151`), + // which still reads the string, but the gate itself must read the boolean. const status = typeof state["Status"] === "string" ? state["Status"] : ""; - const running = status === "running"; + const running = state["Running"] === true; const health = state["Health"]; const healthStatus = isJsonRecord(health) && typeof health["Status"] === "string" ? health["Status"] : undefined; diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts index 39505f964d..0ecc41b372 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -167,7 +167,11 @@ describe("legacyListContainersByLabel", () => { describe("legacyInspectContainerState", () => { it.live("parses a running, healthy container's state", () => { const mock = mockSpawner({ - stdout: JSON.stringify({ Status: "running", Health: { Status: "healthy" } }), + stdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, + }), }); return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( Effect.map((state) => { @@ -183,7 +187,7 @@ describe("legacyInspectContainerState", () => { }); it.live("parses a running container with no health check configured", () => { - const mock = mockSpawner({ stdout: JSON.stringify({ Status: "running" }) }); + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "running", Running: true }) }); return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( Effect.map((state) => { expect(state).toEqual({ running: true, status: "running" }); @@ -192,7 +196,7 @@ describe("legacyInspectContainerState", () => { }); it.live("parses a stopped/exited container", () => { - const mock = mockSpawner({ stdout: JSON.stringify({ Status: "exited" }) }); + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "exited", Running: false }) }); return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( Effect.map((state) => { expect(state).toEqual({ running: false, status: "exited" }); @@ -200,6 +204,22 @@ describe("legacyInspectContainerState", () => { ); }); + it.live( + "treats a paused/restarting container as running, matching Go's boolean-based gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) checks `resp.State.Running`, + // not `resp.State.Status` — a paused or restarting container reports + // `Running: true` alongside a non-"running" status string, and Go + // continues past the not-running branch in that case. + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "paused", Running: true }) }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "paused" }); + }), + ); + }, + ); + it.live( "fails with LegacyDockerLifecycleInspectError, preserving the real stderr, when the container does not exist", () => { From 844f448f3c7c82cb15bcbdf907e1ee695e1c82da Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:03:12 +0100 Subject: [PATCH 36/81] fix(cli): apply SUPABASE_*_ENABLED overrides to every status gate and reject malformed port overrides (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's status.toValues() (internal/status/status.go:55-61) reads utils.Config.*.Enabled after Viper has already applied any SUPABASE_
_ENABLED override (SetEnvPrefix("SUPABASE") + AutomaticEnv() + ExperimentalBindStruct(), pkg/config/config.go:580-586) — generically across api/studio/auth/local_smtp/storage/edge_runtime/ storage.s3_protocol, not just auth. legacyResolveStatusState was gating on the raw decoded config.
.enabled for all of these except the already-fixed auth.enabled, so a stack Go started with e.g. SUPABASE_API_ENABLED=true over a false TOML value would have Kong/ PostgREST running while native `status` omitted them. Threads each gate through the shared legacyEnvOverrideBool helper (now exported from legacy-local-config-values.ts). Also fixes a separate gap in the same file: SUPABASE_*_PORT overrides (db/api/studio/local_smtp) were fed through `Number(...)` with no validation, so a malformed value (e.g. "abc") silently produced a NaN-laced URL. Go's Config.Load decodes these as uint16 via Viper's UnmarshalExact (pkg/config/config.go:749-756), which hard-fails config loading on a bad value — reproduced here as a thrown LegacyInvalidPortEnvOverrideError, which status.handler.ts already surfaces as LegacyStatusInvalidConfigError via its existing Effect.try wrapper around legacyResolveStatusState. --- .../legacy/commands/status/status.handler.ts | 16 +- .../legacy/commands/status/status.values.ts | 72 ++++++-- .../status/status.values.unit.test.ts | 167 ++++++++++++++++++ .../shared/legacy-local-config-values.ts | 105 ++++++++--- .../legacy-local-config-values.unit.test.ts | 26 +++ 5 files changed, 346 insertions(+), 40 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 0f5d25ab47..47d15c71f0 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -226,13 +226,15 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const overrides = yield* parseOverrides(flags.overrideName); // `legacyResolveStatusState` can throw `LegacyInvalidJwtSecretError` (a short - // `auth.jwt_secret`) or a signing-keys-file read/parse error — Go's - // `Config.Validate` rejects both at config-load time, before this command - // would ever render anything, so they're surfaced here as a hard failure - // rather than silently falling back to a default/HMAC-signed key. Resolved - // once and reused for both the real and pretty-mode (empty-override) value - // maps below, so a configured `signing_keys_path` is read and the anon/ - // service_role JWTs signed only once per invocation, not twice. + // `auth.jwt_secret`), `LegacyInvalidPortEnvOverrideError` (a malformed + // `SUPABASE_*_PORT` override), or a signing-keys-file read/parse error — + // Go's `Config.Load`/`Validate` reject all three at config-load time, + // before this command would ever render anything, so they're surfaced + // here as a hard failure rather than silently falling back to a default/ + // HMAC-signed key or a `NaN`-laced URL. Resolved once and reused for both + // the real and pretty-mode (empty-override) value maps below, so a + // configured `signing_keys_path` is read and the anon/service_role JWTs + // signed only once per invocation, not twice. const state = yield* Effect.try({ try: () => legacyResolveStatusState( diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 78f04770f2..ac33fe76e2 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -3,6 +3,7 @@ import type { ProjectConfig } from "@supabase/config"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; import { + legacyEnvOverrideBool, legacyResolveLocalConfigValues, type LegacyLocalConfigValues, } from "../../shared/legacy-local-config-values.ts"; @@ -226,6 +227,7 @@ export interface LegacyStatusState { readonly inbucketEnabled: boolean; readonly storageEnabled: boolean; readonly functionsEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; } /** @@ -239,7 +241,19 @@ export interface LegacyStatusState { * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never * user-overridable and the default image is always the one to check. * + * Each `.enabled` gate is read through {@link legacyEnvOverrideBool}, not the + * raw decoded `config.
.enabled`, because Go's `status.toValues()` + * (`status.go:55-61`) reads `utils.Config.*.Enabled` — a package-level struct + * Viper has already applied any `SUPABASE_
_ENABLED` env/dotenv + * override to (`SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` + + * `ExperimentalBindStruct()`, `pkg/config/config.go:580-586`) — generically, + * not just for `auth.enabled`. Skipping this would mean a stack Go started + * with e.g. `SUPABASE_API_ENABLED=true` over a `false` TOML value has Kong/ + * PostgREST running while native `status` omits them entirely. + * * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. */ @@ -254,22 +268,55 @@ export function legacyResolveStatusState( const local = legacyResolveLocalConfigValues(config, hostname, workdir, projectEnvValues); const isExcluded = (id: string) => excluded.includes(id); - const kongEnabled = - config.api.enabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + projectEnvValues, + ); + const studioSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + projectEnvValues, + ); + const authSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + projectEnvValues, + ); + const inbucketSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + projectEnvValues, + ); + const storageSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + projectEnvValues, + ); + const edgeRuntimeEnabled = legacyEnvOverrideBool( + "SUPABASE_EDGE_RUNTIME_ENABLED", + config.edge_runtime.enabled, + projectEnvValues, + ); + const storageS3ProtocolEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + config.storage.s3_protocol.enabled, + projectEnvValues, + ); + + const kongEnabled = apiEnabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); const postgrestEnabled = kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); const studioEnabled = - config.studio.enabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); + studioSectionEnabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); const authEnabled = - config.auth.enabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); + authSectionEnabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); const inbucketEnabled = - config.local_smtp.enabled && - !isExcluded(containerIds.inbucket) && - !isExcluded(MAILPIT_IMAGE_NAME); + inbucketSectionEnabled && !isExcluded(containerIds.inbucket) && !isExcluded(MAILPIT_IMAGE_NAME); const storageEnabled = - config.storage.enabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); + storageSectionEnabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); const functionsEnabled = - config.edge_runtime.enabled && + edgeRuntimeEnabled && !isExcluded(containerIds.edgeRuntime) && !isExcluded(EDGE_RUNTIME_IMAGE_NAME); @@ -283,6 +330,7 @@ export function legacyResolveStatusState( inbucketEnabled, storageEnabled, functionsEnabled, + storageS3ProtocolEnabled, }; } @@ -295,8 +343,8 @@ export function legacyStatusValuesFromState( state: LegacyStatusState, overrides: ReadonlyMap, ): LegacyStatusValuesResult { - const { config, local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; - const { inbucketEnabled, storageEnabled, functionsEnabled } = state; + const { local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; + const { inbucketEnabled, storageEnabled, functionsEnabled, storageS3ProtocolEnabled } = state; const names = resolveOutputNames(overrides); // Go always sets db.url unconditionally, before any gating (status.go:52). @@ -332,7 +380,7 @@ export function legacyStatusValuesFromState( values[names.mailpitUrl] = local.mailpitUrl; values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; } - if (storageEnabled && config.storage.s3_protocol.enabled) { + if (storageEnabled && storageS3ProtocolEnabled) { values[names.storageS3Url] = local.storageS3Url; values[names.storageS3AccessKeyId] = local.storageS3AccessKeyId; values[names.storageS3SecretAccessKey] = local.storageS3SecretAccessKey; diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts index dd277f56f3..1d597a4fa7 100644 --- a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -495,6 +495,173 @@ describe("legacyStatusValues", () => { }); }); + describe("SUPABASE_*_ENABLED env overrides", () => { + // Go's `status.toValues()` (`status.go:55-61`) reads `utils.Config.*.Enabled` + // AFTER Viper's `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` binding + // (`pkg/config/config.go:580-586`) has already applied any + // `SUPABASE_
_ENABLED` override — generically, not just for auth. + // `legacyResolveStatusState` must read the same post-override value for + // every gate, not the raw decoded `config.
.enabled`. + + it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_API_ENABLED: "true", + }, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeDefined(); + }); + + it("omits API_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_API_ENABLED: "false" }, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + 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", () => { + // Reproduces the exact scenario a Go-started stack can hit: TOML says + // auth is disabled, but the running stack was actually started with + // SUPABASE_AUTH_ENABLED=true from the shell/dotenv, so Auth is up and + // status must still print its credentials. + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_AUTH_ENABLED: "true", + }, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { + const { values } = legacyStatusValues( + 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 } = legacyStatusValues( + 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 } = legacyStatusValues( + 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 } = legacyStatusValues( + 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 } = legacyStatusValues( + 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 } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false" }, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + }); + 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"]]); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 8b1d61db08..fe80067696 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -89,6 +89,55 @@ export class LegacyInvalidJwtSecretError extends Error { /** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ const MIN_JWT_SECRET_LENGTH = 16; +/** + * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port, mirroring Go's `Config.Load` + * (`pkg/config/config.go:749-756`): `v.UnmarshalExact` decodes with + * `WeaklyTypedInput` on (viper's `defaultDecoderConfig`, never reset by our + * decoder options), so mapstructure's `decodeUint` runs `strconv.ParseUint` + * on the override string and hard-fails config loading on a bad value — + * there is no Go code path that reaches `status`/`stop` with a malformed + * port override. The message text isn't a byte-match for mapstructure's + * internal error (that's viper/mapstructure library text, not a Go-authored + * string), but the parity-relevant part — hard-fail, same field name — is. + */ +export class LegacyInvalidPortEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); + this.name = "LegacyInvalidPortEnvOverrideError"; + } +} + +/** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ +const MAX_PORT = 65535; + +/** + * Port-flavored sibling of {@link envOverride}/{@link legacyEnvOverrideBool} + * for `SUPABASE_*_PORT` fields Go decodes as `uint16` rather than a plain + * string. Unlike the boolean sibling — which intentionally falls back to + * `configured` on a malformed override — a bad port override is a genuine + * Go-parity hard failure (see {@link LegacyInvalidPortEnvOverrideError}), not + * a leniency case: Go never proceeds with the pre-override value on a decode + * error, it fails config loading outright. + */ +function envOverridePort( + name: string, + configuredPort: number, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configuredPort; + if (!/^\d+$/.test(value)) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + const port = Number(value); + if (port > MAX_PORT) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + return port; +} + /** * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), @@ -122,18 +171,24 @@ function envOverride( /** * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields Go - * decodes as a native bool (`api.tls.enabled`, `auth.enabled`) rather than a - * string/number — those are bound by the same generic Viper mechanism - * (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()`, - * `pkg/config/config.go:582-586`), but the override string must be decoded - * with Go's own `strconv.ParseBool` acceptance set ({@link legacyParseGoBool}) - * instead of used verbatim. A malformed override (not one of Go's accepted - * bool spellings) falls back to `configured` — matching this module's - * existing leniency for other env-derived fields (e.g. `Number(envOverride(...))` - * on ports isn't hard-validated either) rather than hard-failing the whole - * `stop`/`status` run over a bad override. + * decodes as a native bool (`api.tls.enabled`, `auth.enabled`, and every other + * `
.enabled` gate `status`/`stop` read — see `status.values.ts`) + * rather than a string/number — those are bound by the same generic Viper + * mechanism (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()`, `pkg/config/config.go:582-586`), but the override string + * must be decoded with Go's own `strconv.ParseBool` acceptance set + * ({@link legacyParseGoBool}) instead of used verbatim. A malformed override + * (not one of Go's accepted bool spellings) falls back to `configured` — + * matching this module's existing leniency for other env-derived fields (e.g. + * `Number(envOverride(...))` on ports isn't hard-validated either) rather + * than hard-failing the whole `stop`/`status` run over a bad override. + * + * Exported (not just used internally) because `status.values.ts`'s own + * `
.enabled` gates need this same override treatment — Go's + * `status.toValues()` reads `utils.Config.*.Enabled` post-Viper-override for + * every gated service, not only auth. */ -function envOverrideBool( +export function legacyEnvOverrideBool( name: string, configured: boolean, projectEnvValues: Readonly> | undefined, @@ -206,7 +261,7 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- * overridden value, not necessarily raw `config.auth.enabled` — see - * {@link envOverrideBool}) — Go's `Validate` nests the entire signing-keys read + * {@link legacyEnvOverrideBool}) — Go's `Validate` nests the entire signing-keys read * inside `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`), reading * that same post-override value, so a disabled auth section never touches * `signing_keys_path`, however stale or missing that file is. @@ -238,6 +293,8 @@ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJw /** * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. */ @@ -261,9 +318,9 @@ export function legacyResolveLocalConfigValues( config.api.external_url, projectEnvValues, ), - port: Number(envOverride("SUPABASE_API_PORT", String(config.api.port), projectEnvValues)), + port: envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), tls: { - enabled: envOverrideBool( + enabled: legacyEnvOverrideBool( "SUPABASE_API_TLS_ENABLED", config.api.tls.enabled, projectEnvValues, @@ -272,12 +329,18 @@ export function legacyResolveLocalConfigValues( }, hostname, ); - const dbPort = Number(envOverride("SUPABASE_DB_PORT", String(config.db.port), projectEnvValues)); - const studioPort = Number( - envOverride("SUPABASE_STUDIO_PORT", String(config.studio.port), projectEnvValues), + const dbPort = envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + const studioPort = envOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, ); - const mailpitPort = Number( - envOverride("SUPABASE_LOCAL_SMTP_PORT", String(config.local_smtp.port), projectEnvValues), + const mailpitPort = envOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, ); const jwtSecret = resolveJwtSecret( envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), @@ -295,8 +358,8 @@ export function legacyResolveLocalConfigValues( // only this file read is gated. `c.Auth.Enabled` is itself Viper-bound like // any other field (`config.go:582-586`), so `Validate`'s gate reads the // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence - // `envOverrideBool` here instead of `config.auth.enabled` directly. - const authEnabled = envOverrideBool( + // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. + const authEnabled = legacyEnvOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, projectEnvValues, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 84b06f7461..d2d57ce991 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { LegacyInvalidJwtSecretError, + LegacyInvalidPortEnvOverrideError, legacyResolveLocalConfigValues, } from "./legacy-local-config-values.ts"; @@ -293,6 +294,31 @@ describe("legacyResolveLocalConfigValues", () => { const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); }); + + // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via + // Viper's UnmarshalExact (pkg/config/config.go:749-756, WeaklyTypedInput + // decodes the override string with strconv.ParseUint and hard-fails on a + // malformed value) rather than silently producing a `NaN`-laced URL. + it.each([ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { + process.env[envKey] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { + process.env["SUPABASE_DB_PORT"] = "99999"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); }); describe("SUPABASE_API_TLS_ENABLED env override", () => { From b1f28dae691247ae6151ae55f8e220613faec3b1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:26:02 +0100 Subject: [PATCH 37/81] fix(cli): preserve backslash-escaped $VAR references in extra dotenv files (review: #5765) godotenv's expandVarRegex captures a leading backslash before $ and strips only that backslash, returning the rest of the match literally instead of looking it up (joho/godotenv@v1.5.1 parser.go:253,264-265) - verified against the actual pinned dependency (apps/cli-go/go.mod). SUPABASE_PROJECT_ID=demo\$ID must stay demo$ID, not expand to demo, so stop/status can't be mistargeted by an escaped reference in a project-root or SUPABASE_ENV-selected dotenv file. --- .../shared/legacy-project-environment.ts | 25 +++++++++++++-- .../legacy-project-environment.unit.test.ts | 31 ++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index 8a10cbc4c7..26dc6b0175 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -44,11 +44,30 @@ function candidateDotenvFilenames(env: string): ReadonlyArray { // (`parser.go:157,174-178`); single-quoted values never reach this (`parser.go:172-173`). An // unresolved reference expands to `""` (Go's zero value for a missing map key), not the // literal `$NAME`. +// +// A leading `\` before `$` escapes the reference: godotenv's `expandVarRegex` captures that +// backslash (`parser.go:253`), and its replacer strips ONLY the backslash and returns the rest +// of the match verbatim — `\$FOO`/`\${FOO}` becomes the literal `$FOO`/`${FOO}`, never looked up +// in the map, even when `FOO` is defined (`parser.go:264-265`: `if submatch[1] == "\\" ... return +// submatch[0][1:]`). Verified directly against the real `joho/godotenv@v1.5.1` module (the version +// `apps/cli-go/go.mod` pins) rather than reasoning from the doc comment alone. The earlier +// `\n`/`\r` unescape step in {@link readDotEnvFile} only matches backslash+`n`/`r`, so an +// escaping backslash before `$` survives untouched until this function sees it, matching Go's +// own `expandEscapes` → `expandVariables` order. function expandDotEnvVariable(value: string, values: Readonly>): string { return value.replace( - /\$\{([A-Z0-9_]+)\}|\$([A-Z0-9_]+)/g, - (_match, braced: string | undefined, bare: string | undefined) => - values[braced ?? bare ?? ""] ?? "", + /(\\)?\$(?:\{([A-Z0-9_]+)\}|([A-Z0-9_]+))?/g, + ( + match, + backslash: string | undefined, + braced: string | undefined, + bare: string | undefined, + ) => { + if (backslash !== undefined) return match.slice(1); + if (braced !== undefined) return values[braced] ?? ""; + if (bare !== undefined) return values[bare] ?? ""; + return match; + }, ); } diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index 5e9a6cdc59..e5f8ee4414 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -210,12 +210,41 @@ describe("legacyResolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); }); - it("expands an unresolved reference to an empty string, matching Go's map zero-value", () => { + it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); }); + it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); + }); + + it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { + // godotenv's expandVarRegex captures a leading backslash and strips ONLY + // that backslash, returning the rest of the match verbatim instead of + // doing a lookup (`godotenv@v1.5.1/parser.go:253,264-265`) — even when + // BASE is defined, `demo\$BASE` must stay `demo$BASE`, not become + // `demodemo`. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); + }); + + it("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); + }); + + it("treats a bare trailing $ with no variable name as a literal", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); + }); + describe("when no project was found (projectEnv is null)", () => { // Go's `loadNestedEnv` runs unconditionally before `config.toml` is ever // opened (`pkg/config/config.go:786-793`), so a missing config file must From df513cb815c7cf5b18ac27f85f5c10124d603a56 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:26:09 +0100 Subject: [PATCH 38/81] fix(cli): reject malformed SUPABASE_*_ENABLED bool env overrides (review: #5765) Go decodes SUPABASE_*_ENABLED overrides through the same Viper path as SUPABASE_*_PORT (ExperimentalBindStruct + AutomaticEnv, config.go:580-586), and viper's decoder config defaults WeaklyTypedInput: true, so mapstructure's decodeBool runs a malformed override through strconv.ParseBool and hard-fails Config.Load (config.go:749-756) rather than falling back to the pre-override value. legacyEnvOverrideBool previously swallowed a bad override and silently kept the configured value - the same gap already closed for ports via LegacyInvalidPortEnvOverrideError. Add the analogous LegacyInvalidBoolEnvOverrideError and thread a dottedFieldPath through legacyEnvOverrideBool the same way envOverridePort already does. --- .../legacy/commands/status/status.values.ts | 9 +++++ .../shared/legacy-local-config-values.ts | 40 ++++++++++++++++--- .../legacy-local-config-values.unit.test.ts | 16 ++++---- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index ac33fe76e2..e6e6825714 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -254,6 +254,8 @@ export interface LegacyStatusState { * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} 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 first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. */ @@ -271,36 +273,43 @@ export function legacyResolveStatusState( const apiEnabled = legacyEnvOverrideBool( "SUPABASE_API_ENABLED", config.api.enabled, + "api.enabled", projectEnvValues, ); const studioSectionEnabled = legacyEnvOverrideBool( "SUPABASE_STUDIO_ENABLED", config.studio.enabled, + "studio.enabled", projectEnvValues, ); const authSectionEnabled = legacyEnvOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, + "auth.enabled", projectEnvValues, ); const inbucketSectionEnabled = legacyEnvOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", config.local_smtp.enabled, + "local_smtp.enabled", projectEnvValues, ); const storageSectionEnabled = legacyEnvOverrideBool( "SUPABASE_STORAGE_ENABLED", config.storage.enabled, + "storage.enabled", projectEnvValues, ); const edgeRuntimeEnabled = legacyEnvOverrideBool( "SUPABASE_EDGE_RUNTIME_ENABLED", config.edge_runtime.enabled, + "edge_runtime.enabled", projectEnvValues, ); const storageS3ProtocolEnabled = legacyEnvOverrideBool( "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", config.storage.s3_protocol.enabled, + "storage.s3_protocol.enabled", projectEnvValues, ); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index fe80067696..7be1dd6a08 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -169,6 +169,24 @@ function envOverride( return value !== undefined && value.length > 0 ? value : configured; } +/** + * Thrown by {@link legacyEnvOverrideBool} when a `SUPABASE_*_ENABLED` (or other + * bool-typed) env/dotenv override doesn't parse as one of Go's accepted bool + * spellings, mirroring Go's `Config.Load` (`pkg/config/config.go:749-756`): + * `v.UnmarshalExact` decodes with `WeaklyTypedInput` on (viper's + * `defaultDecoderConfig`, never reset by our decoder options — same mechanism + * as {@link LegacyInvalidPortEnvOverrideError}), so mapstructure's `decodeBool` + * runs `strconv.ParseBool` on the override string and hard-fails config + * loading on a bad value — there is no Go code path that reaches `status`/ + * `stop` with a malformed bool override. + */ +export class LegacyInvalidBoolEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); + this.name = "LegacyInvalidBoolEnvOverrideError"; + } +} + /** * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields Go * decodes as a native bool (`api.tls.enabled`, `auth.enabled`, and every other @@ -177,11 +195,12 @@ function envOverride( * mechanism (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + * `AutomaticEnv()`, `pkg/config/config.go:582-586`), but the override string * must be decoded with Go's own `strconv.ParseBool` acceptance set - * ({@link legacyParseGoBool}) instead of used verbatim. A malformed override - * (not one of Go's accepted bool spellings) falls back to `configured` — - * matching this module's existing leniency for other env-derived fields (e.g. - * `Number(envOverride(...))` on ports isn't hard-validated either) rather - * than hard-failing the whole `stop`/`status` run over a bad override. + * ({@link legacyParseGoBool}) instead of used verbatim. Unlike a plain string + * override — where an unparsed value has no Go-observable failure mode — a + * malformed bool override is a genuine Go-parity hard failure (see + * {@link LegacyInvalidBoolEnvOverrideError}), same as + * {@link LegacyInvalidPortEnvOverrideError} for ports: Go never proceeds with + * the pre-override value on a decode error, it fails config loading outright. * * Exported (not just used internally) because `status.values.ts`'s own * `
.enabled` gates need this same override treatment — Go's @@ -191,11 +210,16 @@ function envOverride( export function legacyEnvOverrideBool( name: string, configured: boolean, + dottedFieldPath: string, projectEnvValues: Readonly> | undefined, ): boolean { const value = envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; - return legacyParseGoBool(value) ?? configured; + const parsed = legacyParseGoBool(value); + if (parsed === undefined) { + throw new LegacyInvalidBoolEnvOverrideError(dottedFieldPath, value); + } + return parsed; } /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ @@ -295,6 +319,8 @@ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJw * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} 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 first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. */ @@ -323,6 +349,7 @@ export function legacyResolveLocalConfigValues( enabled: legacyEnvOverrideBool( "SUPABASE_API_TLS_ENABLED", config.api.tls.enabled, + "api.tls.enabled", projectEnvValues, ), }, @@ -362,6 +389,7 @@ export function legacyResolveLocalConfigValues( const authEnabled = legacyEnvOverrideBool( "SUPABASE_AUTH_ENABLED", config.auth.enabled, + "auth.enabled", projectEnvValues, ); const signingKey = diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index d2d57ce991..a24cad2864 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { + LegacyInvalidBoolEnvOverrideError, LegacyInvalidJwtSecretError, LegacyInvalidPortEnvOverrideError, legacyResolveLocalConfigValues, @@ -351,11 +352,12 @@ describe("legacyResolveLocalConfigValues", () => { expect(values.apiUrl).toBe("http://config.example"); }); - it("falls back to the configured value for a malformed override, matching this module's leniency", () => { + it("rejects a malformed override instead of falling back to the configured value", () => { process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); }); it("treats an empty override as unset, matching Viper's default", () => { @@ -510,14 +512,14 @@ describe("legacyResolveLocalConfigValues", () => { expect(values.anonKey.split(".")).toHaveLength(3); }); - it("falls back to the configured value for a malformed override", () => { + it("rejects a malformed override instead of falling back to the configured value", () => { process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false, signing_keys_path: "missing.json" }, }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); }); }); }); From 9f8a7847441577ed7e8d467647bbe1245dabcfa5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:26:15 +0100 Subject: [PATCH 39/81] fix(cli): parse --override-name before touching config or Docker in status (review: #5765) Go's status command validates --override-name in Cobra's PreRunE (cmd/status.go:21-27), and Cobra's execute loop returns as soon as PreRunE errors without ever calling RunE (cobra@v1.10.2 command.go:999-1015) - so status.Run's config load and Docker calls (internal/status/status.go:101-116) are unreachable when --override-name is malformed. The TS port called parseOverrides after config load, the health check, and container listing, so a typo'd --override-name could be masked by a Docker/DB error or a "Stopped services" line instead of failing immediately. Move the override parse to the top of the handler to match Go's PreRunE-before-RunE ordering. --- .../legacy/commands/status/status.handler.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 47d15c71f0..4418b7e6cc 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -93,7 +93,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; yield* Effect.gen(function* () { - // 1. `status` always needs config, unlike `stop` (status.go:99-103). An + // 1. `--override-name KEY=VALUE` parsing, FIRST — mirroring Go's Cobra + // wiring, where override validation runs in `PreRunE` (`cmd/status.go: + // 21-27`) and Cobra's execute loop returns as soon as `PreRunE` errors, + // never calling `RunE` (`spf13/cobra@v1.10.2/command.go:999-1015`). So a + // malformed `--override-name` entry fails before `status.Run` ever loads + // config or touches Docker (`internal/status/status.go:101-116`) — it + // must win over a config-load error or a Docker/DB health-check error, + // not be masked by either. `overrides` itself is only consumed much + // later, by `legacyStatusValuesFromState` below. + const overrides = yield* parseOverrides(flags.overrideName); + + // 2. `status` always needs config, unlike `stop` (status.go:99-103). An // ABSENT config.toml is not a hard failure in Go: `flags.LoadConfig` -> // `Config.Load` -> `loadFromFile` -> `mergeFileConfig` treats a missing // file as a no-op (`os.ErrNotExist` -> nil, pkg/config/config.go:655-656) @@ -157,7 +168,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); - // 2. status has no --project-id flag; resolution is always env → toml → + // 3. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton Go's // `Config.Validate` produces once at config-load time // (`pkg/config/config.go:938-944`) — every reader, including the Docker @@ -173,7 +184,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const dbContainerId = localDbContainerId(projectId); - // 3. Health check, skipped entirely with --ignore-health-check (status.go:104-108). + // 4. Health check, skipped entirely with --ignore-health-check (status.go:104-108). // Go's `assertContainerHealthy` never special-cases "not found" — an absent // container fails `ContainerInspect` itself, which surfaces as the generic // inspect error (status.go:147-150), not the "not running" branch (which @@ -200,7 +211,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS } } - // 4. List running containers, diff against the 13 expected service ids + // 5. List running containers, diff against the 13 expected service ids // (status.go:125-145), and report any that are stopped. const filterValue = legacyCliProjectFilterValue(projectId); const runningNames = yield* legacyListContainersByLabel(spawner, { @@ -215,20 +226,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS yield* output.raw(`Stopped services: ${formatGoStringSlice(stopped)}\n`, "stderr"); } - // 5. Merge health-derived exclusions with the user's --exclude flag. + // 6. Merge health-derived exclusions with the user's --exclude flag. const excluded = [...stopped, ...flags.exclude]; - // 6. Build the value map (Go's toValues()). + // 7. Build the value map (Go's toValues()). const containerIds = legacyStatusContainerIds(projectId); const hostname = legacyGetHostname(); - // 7. --override-name KEY=VALUE parsing. - const overrides = yield* parseOverrides(flags.overrideName); - // `legacyResolveStatusState` can throw `LegacyInvalidJwtSecretError` (a short - // `auth.jwt_secret`), `LegacyInvalidPortEnvOverrideError` (a malformed - // `SUPABASE_*_PORT` override), or a signing-keys-file read/parse error — - // Go's `Config.Load`/`Validate` reject all three at config-load time, + // `auth.jwt_secret`), `LegacyInvalidPortEnvOverrideError`/ + // `LegacyInvalidBoolEnvOverrideError` (a malformed `SUPABASE_*_PORT`/ + // `SUPABASE_*_ENABLED` override), or a signing-keys-file read/parse error — + // Go's `Config.Load`/`Validate` reject all of these at config-load time, // before this command would ever render anything, so they're surfaced // here as a hard failure rather than silently falling back to a default/ // HMAC-signed key or a `NaN`-laced URL. Resolved once and reused for both From 866daf0833a71e2bb4c9fdfa32ea1b6abcc177c7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:34:25 +0100 Subject: [PATCH 40/81] fix(cli): reject a zero SUPABASE_DB_PORT override (review: #5765) Go's Config.Validate hard-fails db.port == 0 unconditionally with "Missing required field in config: db.port" (pkg/config/config.go:1031-1032), running after Viper applies any SUPABASE_DB_PORT override - so a zero override must fail the same way instead of surfacing as DB_URL=...:0/postgres. Scoped to db.port only (not envOverridePort generically): api.port/studio.port/ local_smtp.port's zero-rejection in Go is conditional on their section's enabled flag (config.go:1006-1009,1070-1073,1081-1084), so db is the only field where zero is unconditionally invalid. Matches the existing message already used for the same field in the db query/test db path (legacy-db-config.toml-read.ts:1380). --- .../legacy/shared/legacy-local-config-values.ts | 14 ++++++++++++++ .../shared/legacy-local-config-values.unit.test.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 7be1dd6a08..a1233379d8 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -356,7 +356,21 @@ export function legacyResolveLocalConfigValues( }, hostname, ); + // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no + // `enabled` gate in Go's `Config.Validate` — it's unconditionally required, + // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this + // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` + // render anything, same wording already used for the `db query`/`test db` + // path (`legacy-db-config.toml-read.ts:1380`). This check is intentionally + // NOT inside `envOverridePort` itself: that helper is generic across all + // four port fields, and Go's zero-rejection for the other three is + // conditional on their section's `enabled` flag (`config.go:1006-1009, + // 1070-1073,1081-1084`), so adding it there would wrongly reject e.g. + // `SUPABASE_STUDIO_PORT=0` even when `studio.enabled` is `false`. const dbPort = envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + if (dbPort === 0) { + throw new Error("Missing required field in config: db.port"); + } const studioPort = envOverridePort( "SUPABASE_STUDIO_PORT", config.studio.port, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index a24cad2864..293a4d4cae 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -320,6 +320,19 @@ describe("legacyResolveLocalConfigValues", () => { LegacyInvalidPortEnvOverrideError, ); }); + + // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, + // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a + // Config.Validate-time hard-fail specific to db.port: it has no `enabled` + // gate in Go, unlike api.port/studio.port/local_smtp.port + // (pkg/config/config.go:1006-1009,1031-1032,1070-1073,1081-1084). + it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { + process.env["SUPABASE_DB_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: db.port", + ); + }); }); describe("SUPABASE_API_TLS_ENABLED env override", () => { From 31b502591c3144c9b2c0f609b47cf941ebe58c92 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 14:39:51 +0100 Subject: [PATCH 41/81] fix(cli): validate status config before any Docker probe (review: #5765) Go's status.Run calls flags.LoadConfig (config load + Validate - jwt-secret length, SUPABASE_*_PORT/SUPABASE_*_ENABLED overrides, signing keys) entirely before assertContainerHealthy/container listing (internal/status/status.go: 101-116; pkg/config/config.go:882; pkg/config/apikeys.go:47). The port resolved the equivalent config validation into legacyResolveStatusState, but called it after the health check and container listing, so the same misconfiguration could surface as a Docker/DB error instead of the real validation error whenever the local stack was unavailable. Split legacyResolveStatusState into legacyResolveStatusLocalState (the throwing config-derivation half, no dependency on excluded/containerIds) and legacyGateStatusState (the pure exclude-based gating half, which does need Docker-derived state) - status.handler.ts now resolves+validates the former right after config load, before Docker, and applies the latter afterward. legacyStatusValues composes both internally so existing test callers are unaffected. Also fixes an integration test that asserted `ps` was called for a too-short jwt_secret, which encoded the pre-fix (wrong) ordering. --- .../legacy/commands/status/status.handler.ts | 70 ++++++------ .../status/status.integration.test.ts | 6 +- .../legacy/commands/status/status.values.ts | 108 +++++++++++++----- .../status/status.values.unit.test.ts | 4 +- 4 files changed, 121 insertions(+), 67 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 4418b7e6cc..6110154c8e 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -39,7 +39,8 @@ import { import { legacyRenderStatusPretty } from "./status.pretty.ts"; import { LEGACY_STATUS_FIELDS, - legacyResolveStatusState, + legacyGateStatusState, + legacyResolveStatusLocalState, legacyStatusContainerIds, legacyStatusValuesFromState, } from "./status.values.ts"; @@ -168,7 +169,29 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); - // 3. status has no --project-id flag; resolution is always env → toml → + // 3. Resolve + VALIDATE config-derived state before any Docker call — + // matching Go's `flags.LoadConfig` (config load + `Validate`, + // `internal/utils/flags/config_path.go:12` -> `pkg/config/config.go:882`), + // which runs entirely before `assertContainerHealthy`/container listing + // (`internal/status/status.go:101-116`). `legacyResolveStatusLocalState` + // can throw `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`), + // `LegacyInvalidPortEnvOverrideError`/`LegacyInvalidBoolEnvOverrideError` + // (a malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override), or a + // signing-keys-file read/parse error — all of these must fail here, not + // be masked by a Docker/DB error when the local stack happens to be + // unavailable. `hostname` has no Docker dependency either, so it's + // resolved here rather than later. + const hostname = legacyGetHostname(); + const localState = yield* Effect.try({ + try: () => + legacyResolveStatusLocalState(config, hostname, cliConfig.workdir, projectEnvValues), + catch: (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + // 4. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton Go's // `Config.Validate` produces once at config-load time // (`pkg/config/config.go:938-944`) — every reader, including the Docker @@ -184,7 +207,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const dbContainerId = localDbContainerId(projectId); - // 4. Health check, skipped entirely with --ignore-health-check (status.go:104-108). + // 5. Health check, skipped entirely with --ignore-health-check (status.go:104-108). // Go's `assertContainerHealthy` never special-cases "not found" — an absent // container fails `ContainerInspect` itself, which surfaces as the generic // inspect error (status.go:147-150), not the "not running" branch (which @@ -211,7 +234,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS } } - // 5. List running containers, diff against the 13 expected service ids + // 6. List running containers, diff against the 13 expected service ids // (status.go:125-145), and report any that are stopped. const filterValue = legacyCliProjectFilterValue(projectId); const runningNames = yield* legacyListContainersByLabel(spawner, { @@ -226,39 +249,16 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS yield* output.raw(`Stopped services: ${formatGoStringSlice(stopped)}\n`, "stderr"); } - // 6. Merge health-derived exclusions with the user's --exclude flag. + // 7. Merge health-derived exclusions with the user's --exclude flag. const excluded = [...stopped, ...flags.exclude]; - // 7. Build the value map (Go's toValues()). + // 8. Apply the exclude-based gating on top of the already-validated + // `localState` (Go's `toValues()` exclude filtering, `status.go:55-61`). + // Pure/non-throwing — see `legacyGateStatusState`'s doc comment. Reused + // for both the real and pretty-mode (empty-override) value maps below, + // matching this handler's pre-split behavior. const containerIds = legacyStatusContainerIds(projectId); - const hostname = legacyGetHostname(); - - // `legacyResolveStatusState` can throw `LegacyInvalidJwtSecretError` (a short - // `auth.jwt_secret`), `LegacyInvalidPortEnvOverrideError`/ - // `LegacyInvalidBoolEnvOverrideError` (a malformed `SUPABASE_*_PORT`/ - // `SUPABASE_*_ENABLED` override), or a signing-keys-file read/parse error — - // Go's `Config.Load`/`Validate` reject all of these at config-load time, - // before this command would ever render anything, so they're surfaced - // here as a hard failure rather than silently falling back to a default/ - // HMAC-signed key or a `NaN`-laced URL. Resolved once and reused for both - // the real and pretty-mode (empty-override) value maps below, so a - // configured `signing_keys_path` is read and the anon/service_role JWTs - // signed only once per invocation, not twice. - const state = yield* Effect.try({ - try: () => - legacyResolveStatusState( - config, - containerIds, - hostname, - excluded, - cliConfig.workdir, - projectEnvValues, - ), - catch: (cause) => - new LegacyStatusInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const state = legacyGateStatusState(localState, containerIds, excluded); const { values } = legacyStatusValuesFromState(state, overrides); // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty @@ -277,7 +277,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); }); - // 8. Output branching: Go's -o (env|json|toml|yaml|pretty) is a complete + // 9. Output branching: Go's -o (env|json|toml|yaml|pretty) is a complete // format choice and takes priority over --output-format (root.ts:119-121, // matching functions/list's list.handler.ts:115-118) — only an ABSENT -o // defers to --output-format for json/stream-json. diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 56bbb7de73..0c88abb2a8 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -309,7 +309,9 @@ describe("legacy status integration", () => { it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { // Go's Config.Validate rejects this at config-load time - // (pkg/config/apikeys.go:45-47), before any command can render output. + // (pkg/config/apikeys.go:45-47), entirely before assertContainerHealthy/ + // container listing (internal/status/status.go:101-116) — so no Docker + // call happens, same as the malformed config.toml case above. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', }); @@ -322,7 +324,7 @@ describe("legacy status integration", () => { "Invalid config for auth.jwt_secret. Must be at least 16 characters", ); } - expect(child.spawned.some((s) => s.args[0] === "ps")).toBe(true); + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index e6e6825714..aeb41cf108 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -231,15 +231,38 @@ export interface LegacyStatusState { } /** - * Port of the non-override-dependent half of Go's `(*CustomName).toValues(exclude...)` - * (`internal/status/status.go:50-97`): resolves local config values (URLs, keys — - * can throw, see {@link legacyResolveLocalConfigValues}) and the per-service gating - * booleans. `excluded` matches each gated service by its container id - * (`legacyStatusContainerIds`) OR its default Docker image short name - * (`legacyShortContainerImageName` above) — the 6 relevant Go config fields - * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, - * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never - * user-overridable and the default image is always the one to check. + * The config-load/`Validate`-equivalent half of {@link LegacyStatusState} — + * everything that can THROW, and none of it depends on `excluded`/ + * `containerIds`. Split out so `status.handler.ts` can resolve and validate + * this before any Docker call, matching Go's `flags.LoadConfig` (config load + * + `Validate`, `internal/utils/flags/config_path.go:12` -> + * `pkg/config/config.go:882`) running entirely before `assertContainerHealthy`/ + * container listing (`internal/status/status.go:101-116`) — a bad + * `auth.jwt_secret` or malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` + * override must fail here, not be masked by a Docker/DB error when the local + * stack happens to be unavailable. + */ +export interface LegacyStatusLocalState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly apiEnabled: boolean; + readonly studioSectionEnabled: boolean; + readonly authSectionEnabled: boolean; + readonly inbucketSectionEnabled: boolean; + readonly storageSectionEnabled: boolean; + readonly edgeRuntimeEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * Port of the throwing, non-Docker-dependent half of Go's + * `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`): + * resolves local config values (URLs, keys — can throw, see + * {@link legacyResolveLocalConfigValues}) and the per-service `.enabled` gates, + * with NO reference to `excluded`/`containerIds` — see {@link legacyGateStatusState} + * for the Docker-dependent, non-throwing half this composes with (in + * `status.handler.ts`, or via {@link legacyStatusValues} for callers that + * don't need to run validation before Docker calls). * * Each `.enabled` gate is read through {@link legacyEnvOverrideBool}, not the * raw decoded `config.
.enabled`, because Go's `status.toValues()` @@ -259,16 +282,13 @@ export interface LegacyStatusState { * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. */ -export function legacyResolveStatusState( +export function legacyResolveStatusLocalState( config: ProjectConfig, - containerIds: LegacyStatusContainerIds, hostname: string, - excluded: ReadonlyArray, workdir: string, projectEnvValues: Readonly> | undefined = undefined, -): LegacyStatusState { +): LegacyStatusLocalState { const local = legacyResolveLocalConfigValues(config, hostname, workdir, projectEnvValues); - const isExcluded = (id: string) => excluded.includes(id); const apiEnabled = legacyEnvOverrideBool( "SUPABASE_API_ENABLED", @@ -313,6 +333,41 @@ export function legacyResolveStatusState( projectEnvValues, ); + return { + config, + local, + apiEnabled, + studioSectionEnabled, + authSectionEnabled, + inbucketSectionEnabled, + storageSectionEnabled, + edgeRuntimeEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * The Docker-dependent, non-throwing half of Go's `toValues()`: applies + * `excluded` (matching each gated service by its container id + * (`legacyStatusContainerIds`) OR its default Docker image short name + * (`legacyShortContainerImageName` above) — the 6 relevant Go config fields + * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, + * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never + * user-overridable and the default image is always the one to check) on top of + * an already-resolved {@link LegacyStatusLocalState}. Pure: every throwing + * concern already ran in {@link legacyResolveStatusLocalState}. + */ +export function legacyGateStatusState( + localState: LegacyStatusLocalState, + containerIds: LegacyStatusContainerIds, + excluded: ReadonlyArray, +): LegacyStatusState { + const { config, local } = localState; + const { apiEnabled, studioSectionEnabled, authSectionEnabled } = localState; + const { inbucketSectionEnabled, storageSectionEnabled } = localState; + const { edgeRuntimeEnabled, storageS3ProtocolEnabled } = localState; + const isExcluded = (id: string) => excluded.includes(id); + const kongEnabled = apiEnabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); const postgrestEnabled = kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); @@ -346,7 +401,7 @@ export function legacyResolveStatusState( /** * Applies `--override-name` remapping to an already-resolved {@link LegacyStatusState}. * Pure and non-throwing — every failure mode of `toValues()` lives in - * {@link legacyResolveStatusState}, which runs once per `status` invocation. + * {@link legacyResolveStatusLocalState}, which runs once per `status` invocation. */ export function legacyStatusValuesFromState( state: LegacyStatusState, @@ -400,11 +455,14 @@ export function legacyStatusValuesFromState( } /** - * Convenience wrapper combining {@link legacyResolveStatusState} + - * {@link legacyStatusValuesFromState} in one call — used directly by tests that - * only need a single override map. `status.handler.ts` calls the two halves - * separately so it can resolve state once and reuse it for both the real and - * pretty-mode (empty-override) value maps without recomputing `local`. + * Convenience wrapper combining {@link legacyResolveStatusLocalState} + + * {@link legacyGateStatusState} + {@link legacyStatusValuesFromState} in one + * call — used directly by tests that only need a single override map. + * `status.handler.ts` calls the three separately instead, so it can resolve + + * validate `localState` before any Docker call (see + * {@link legacyResolveStatusLocalState}'s doc comment), and reuse the gated + * `state` for both the real and pretty-mode (empty-override) value maps + * without recomputing `local`. */ export function legacyStatusValues( config: ProjectConfig, @@ -415,13 +473,7 @@ export function legacyStatusValues( workdir: string, projectEnvValues: Readonly> | undefined = undefined, ): LegacyStatusValuesResult { - const state = legacyResolveStatusState( - config, - containerIds, - hostname, - excluded, - workdir, - projectEnvValues, - ); + const localState = legacyResolveStatusLocalState(config, hostname, workdir, projectEnvValues); + const state = legacyGateStatusState(localState, containerIds, excluded); return legacyStatusValuesFromState(state, overrides); } diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts index 1d597a4fa7..3ae5f60004 100644 --- a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -500,8 +500,8 @@ describe("legacyStatusValues", () => { // AFTER Viper's `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` binding // (`pkg/config/config.go:580-586`) has already applied any // `SUPABASE_
_ENABLED` override — generically, not just for auth. - // `legacyResolveStatusState` must read the same post-override value for - // every gate, not the raw decoded `config.
.enabled`. + // `legacyResolveStatusLocalState` must read the same post-override value + // for every gate, not the raw decoded `config.
.enabled`. it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { const config = baseConfig({ api: { enabled: false } }); From 46d0a8886438ded71c60b37f39ef48c2f63fd37c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:08:24 +0100 Subject: [PATCH 42/81] fix(cli): treat stop --all=false as mutually exclusive with --project-id (review: #5765) Cobra's MarkFlagsMutuallyExclusive checks flag presence (Changed), not value, so Go rejects `stop --project-id x --all=false` too. Model --all as Option so the mutex check can see presence separately from the value stop.go:17 reads to skip the config lookup. --- .../src/legacy/commands/stop/stop.command.ts | 10 ++++++++ .../src/legacy/commands/stop/stop.handler.ts | 10 ++++++-- .../commands/stop/stop.integration.test.ts | 25 ++++++++++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index b69683c700..64fb443c6b 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -25,8 +25,18 @@ const config = { noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), ), + // Modelled as `Option` (presence = pflag `Changed`), not a plain + // boolean: Cobra's `MarkFlagsMutuallyExclusive("project-id", "all")` + // (`apps/cli-go/cmd/stop.go:31`) rejects the command whenever BOTH flags + // were explicitly set, regardless of the value `--all` was set to — the + // vendored cobra@v1.10.2 `flag_groups.go:139` check is + // `groupStatus[group][name] = flag.Changed`, not the flag's boolean value. + // A plain `Flag.boolean` here would make `--project-id x --all=false` + // indistinguishable from `--project-id x` (no `--all` at all), silently + // accepting a combination Go rejects. all: Flag.boolean("all").pipe( Flag.withDescription("Stop all local Supabase instances from all projects across the machine."), + Flag.optional, ), } as const; diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 57711dc93d..b59f21ef53 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -73,7 +73,10 @@ import { */ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { - if (flags.all) return ""; + // `internal/stop/stop.go:17`'s `if !all` reads the resolved value (not + // presence), so this branch stays value-based — `Option.getOrElse` mirrors + // Cobra's `BoolVar` default of `false` when `--all` was never passed. + if (Option.getOrElse(flags.all, () => false)) return ""; if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { return flags.projectId.value; } @@ -147,7 +150,10 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; yield* Effect.gen(function* () { - if (Option.isSome(flags.projectId) && flags.all) { + // Presence-based, matching Cobra's `Changed` check (see the doc comment on + // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` + // must reject too, not just `--all`/`--all=true`. + if (Option.isSome(flags.projectId) && Option.isSome(flags.all)) { return yield* Effect.fail( new LegacyStopMutuallyExclusiveError({ // Cobra's `validateExclusiveFlagGroups` (spf13/cobra flag_groups.go): diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index cc194bd087..b14d69204b 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -22,7 +22,7 @@ function flags(overrides: Partial = {}): LegacyStopFlags { projectId: Option.none(), backup: true, noBackup: false, - all: false, + all: Option.none(), ...overrides, }; } @@ -288,7 +288,7 @@ describe("legacy stop integration", () => { it.live("stops every project's containers with --all without reading config.toml", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop(flags({ all: true })); + yield* legacyStop(flags({ all: Option.some(true) })); const psCall = child.spawned.find((s) => s.args[0] === "ps"); expect(psCall?.args).toEqual([ "ps", @@ -317,7 +317,7 @@ describe("legacy stop integration", () => { route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), }); return Effect.gen(function* () { - yield* legacyStop(flags({ all: true })); + yield* legacyStop(flags({ all: Option.some(true) })); expect(out.stderrText).toContain( "Local data are backed up to docker volume. Use docker to show them:", ); @@ -481,7 +481,24 @@ describe("legacy stop integration", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { const exit = yield* Effect.exit( - legacyStop(flags({ projectId: Option.some("other-project"), all: true })), + legacyStop(flags({ projectId: Option.some("other-project"), all: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's `MarkFlagsMutuallyExclusive` mutex is presence-based (`Changed`), + // not value-based — `--all=false` still counts as "set" alongside + // `--project-id`, so this must reject too, not just `--all`/`--all=true`. + it.live("rejects --project-id together with an explicit --all=false", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: Option.some(false) })), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { From b081bbd597827634e544ca730b7aab1402179376 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:11:13 +0100 Subject: [PATCH 43/81] fix(config): match Go's case-agnostic env() reference pattern (review: #5765) Go's LoadEnvHook regex (^env\(.*\)$) has no case/character restriction on the captured variable name, but @supabase/config's matcher was uppercase-only, so a Go-valid reference like project_id = "env(project_id)" never substituted natively. Widen ENV_PATTERN/ENV_CAPTURE_REGEX to Go's exact pattern. --- packages/config/src/io.unit.test.ts | 22 ++++++++++++++++++++++ packages/config/src/lib/env.ts | 10 ++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index c4272f1654..bcad76a9fc 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1053,6 +1053,28 @@ enabled = true } }); + test("resolves env() on a lowercase-named variable, matching Go's case-agnostic matcher", async () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:11`) is + // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so + // `project_id = "env(project_id)"` resolves against a same-case env var + // in the Go CLI. This isn't specific to `project_id`; any string field + // goes through the same pre-decode walk. + const previous = process.env.project_id; + process.env.project_id = "lowercase-ref"; + const cwd = await writeTomlProject(`project_id = "env(project_id)"\n`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.project_id).toBe("lowercase-ref"); + } finally { + if (previous === undefined) { + delete process.env.project_id; + } else { + process.env.project_id = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolves env() references inside the matching remote before merge", async () => { const previous = process.env.SUPABASE_REMOTE_MAX_ROWS_TEST; process.env.SUPABASE_REMOTE_MAX_ROWS_TEST = "777"; diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 8c9c46756f..7eef992016 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -1,7 +1,13 @@ import { Schema, SchemaAST } from "effect"; -export const ENV_PATTERN = "^env\\([A-Z_][A-Z0-9_]*\\)$"; -export const ENV_CAPTURE_REGEX = /^env\(([A-Z_][A-Z0-9_]*)\)$/; +// Go's `LoadEnvHook` matcher (`apps/cli-go/pkg/config/decode_hooks.go:11`) is +// `^env\((.*)\)$` — permissive on the captured name's case/content, and +// reused verbatim for secrets (`secret.go:99`) and the unset-var warning +// (`config.go:1195`). Matching that exactly (not an uppercase-only +// restriction) so e.g. `project_id = "env(project_id)"` substitutes the same +// way it does in the Go CLI. +export const ENV_PATTERN = "^env\\((.*)\\)$"; +export const ENV_CAPTURE_REGEX = /^env\((.*)\)$/; const envRegex = new RegExp(ENV_PATTERN); export function isEnvReference(value: string): boolean { From f4b4292e6f340c620d8d856f71fed222c252c565 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:16:35 +0100 Subject: [PATCH 44/81] fix(cli): validate api.tls cert/key pairing before status prints URLs (review: #5765) Go's Config.Validate (pkg/config/config.go:1006-1027) rejects an api.tls.enabled config with only one of cert_path/key_path set, or an unreadable configured file, before any command renders output. The status port only branched on tls.enabled for http/https scheme selection, so it could print healthy https:// endpoints for a config Go refuses to load. Port the same validation, gated on the post-override api.enabled/api.tls.enabled values. --- .../legacy/commands/status/SIDE_EFFECTS.md | 35 ++--- .../shared/legacy-local-config-values.ts | 88 +++++++++++-- .../legacy-local-config-values.unit.test.ts | 124 ++++++++++++++++++ 3 files changed, 223 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 53b04e9144..bc43278256 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -2,10 +2,11 @@ ## Files Read -| Path | Format | When | -| ------------------------------------------------------ | ------ | -------------------------------------------------------- | -| `/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 | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------- | +| `/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 | ## Files Written @@ -43,18 +44,20 @@ The `SUPABASE_AUTH_*` vars mirror Go's Viper `AutomaticEnv` (`SetEnvPrefix("SUPA ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — status displayed | -| `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | -| `1` | `supabase/config.toml` missing or malformed | -| `1` | a malformed `--override-name` entry | -| `1` | listing running containers failed (Docker daemon unreachable, etc.) | -| `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | -| `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | -| `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | -| `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | -| `1` | `auth.signing_keys_path` is configured but the file is missing/malformed, or its first key's algorithm is not `RS256`/`ES256` | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success — status displayed | +| `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | +| `1` | `supabase/config.toml` missing or malformed | +| `1` | a malformed `--override-name` entry | +| `1` | listing running containers failed (Docker daemon unreachable, etc.) | +| `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | +| `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `auth.signing_keys_path` is configured but the file is missing/malformed, or its first key's algorithm is not `RS256`/`ES256` | +| `1` | `api.enabled` and `api.tls.enabled` are true and only one of `api.tls.cert_path`/`key_path` is set (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `api.enabled` and `api.tls.enabled` are true, both `cert_path` and `key_path` are set, but one of the files can't be read | ## Telemetry Events Fired diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index a1233379d8..f6cb776ca0 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -315,12 +315,69 @@ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJw return jwks[0]; } +/** + * Go's `Config.Validate` TLS branch (`pkg/config/config.go:1006-1027`), gated + * on `api.enabled` same as the caller: a cert path with no key path (or vice + * versa) is a hard config error; when both are set, each file is read to + * confirm it's actually reachable, matching Go's `fs.ReadFile` calls (Go + * caches the bytes for `start` to serve as `CertContent`/`KeyContent` — + * `status` has no use for the bytes, only the same validation outcome, so + * they're discarded here). Neither path set is NOT an error in Go — `Validate` + * only rejects the "exactly one set" case, so `tls.enabled = true` with no + * cert/key configured at all still loads (it just can't serve TLS at `start` + * time), mirrored here by returning without throwing. + * + * Go joins both paths unconditionally with the `supabase/` dir — no + * `filepath.IsAbs` guard (`config.go:961-965` uses `path.Join`, which absorbs + * a leading `/`) — unlike {@link loadFirstSigningKey}'s `signing_keys_path`, + * which Go does guard with `filepath.IsAbs` (`config.go:928-929`). Matches the + * identical Kong-side validation already ported for `seed buckets`/`storage` + * in `legacy-storage-credentials.ts`'s `validateLocalKongTls`. + * + * Uses `node:fs` directly for the same reason as {@link loadFirstSigningKey}: + * this stays a plain synchronous resolver rather than threading the Effect + * `FileSystem` service through `legacyStatusValues`/`status.handler.ts`. + */ +function validateLocalApiTls( + workdir: string, + certPath: string | undefined, + keyPath: string | undefined, +): void { + const hasCert = certPath !== undefined && certPath.length > 0; + const hasKey = keyPath !== undefined && keyPath.length > 0; + + if (hasCert && !hasKey) { + throw new Error("Missing required field in config: api.tls.key_path"); + } + if (hasKey && !hasCert) { + throw new Error("Missing required field in config: api.tls.cert_path"); + } + if (!hasCert) return; + + try { + readFileSync(join(workdir, "supabase", certPath), "utf8"); + } catch (cause) { + throw new Error( + `failed to read TLS cert: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + try { + readFileSync(join(workdir, "supabase", keyPath!), "utf8"); + } catch (cause) { + throw new Error( + `failed to read TLS key: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } +} + /** * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv * override doesn't parse as a valid port. * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv * override doesn't parse as a valid bool. + * @throws when `api.tls.enabled` is set with only one of `cert_path`/`key_path`, or a + * configured file can't be read — see {@link validateLocalApiTls}. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. */ @@ -337,6 +394,28 @@ export function legacyResolveLocalConfigValues( // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- // `scheme://host:port` derivation (which picks `https` vs `http` from // `tls.enabled`) must be the overridden ones too. + const apiTlsEnabled = legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); + // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` + // (`config.go:1006,1010`) — mirroring `authEnabled` below, gate on the + // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + if (apiEnabled && apiTlsEnabled) { + validateLocalApiTls( + workdir, + envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues), + envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues), + ); + } const apiExternalUrl = legacyResolveApiExternalUrl( { external_url: envOverride( @@ -345,14 +424,7 @@ export function legacyResolveLocalConfigValues( projectEnvValues, ), port: envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), - tls: { - enabled: legacyEnvOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - }, + tls: { enabled: apiTlsEnabled }, }, hostname, ); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 293a4d4cae..c17042d8e9 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -536,4 +536,128 @@ describe("legacyResolveLocalConfigValues", () => { }); }); }); + + describe("api.tls (cert/key validation)", () => { + const tempRoot = useLegacyTempWorkdir("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); + } + + it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { + // Go's Validate only rejects the "exactly one set" case (config.go:1010-1027); + // tls.enabled with nothing configured still loads. + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("rejects cert_path set without key_path", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ api: { tls: { enabled: true, cert_path: "cert.pem" } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.key_path", + ); + }); + + it("rejects key_path set without cert_path", () => { + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ api: { tls: { enabled: true, key_path: "key.pem" } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.cert_path", + ); + }); + + 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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + + 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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS key: ", + ); + }); + + 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(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { + // Go's `path.Join` (config.go:961-965) absorbs a leading "/" — unlike + // signing_keys_path, which Go DOES guard with filepath.IsAbs. + 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(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Go's `Validate` nests the whole TLS branch inside `if c.Api.Enabled` + // (config.go:1006,1010) — a disabled api section never validates cert/key, + // however invalid the pairing. + it("skips TLS validation entirely when api is disabled", () => { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(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"]; + }); + + it("skips TLS validation when api is disabled only via env", () => { + process.env["SUPABASE_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(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"; + const config = baseConfig({ + api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.key_path", + ); + }); + }); + }); }); From 21ac0c711742d1d3a0248d2888e6b0d02e4fe24d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:20:19 +0100 Subject: [PATCH 45/81] fix(cli): preserve multiline quoted dotenv values in stop/status extra .env files (review: #5765) godotenv's parser scans the whole buffer with a cursor, so a quoted value spanning physical lines (a pasted PEM key) doesn't break parsing of the rest of the file. legacy-project-environment.ts's readDotEnvFile split content into physical lines first, so a continuation line of an unrelated multiline value looked malformed and aborted the whole file. Delegate to legacy-dotenv.ts's existing cursor-based parseDotEnv instead of the hand-rolled line-by-line scan. --- apps/cli/src/legacy/shared/legacy-dotenv.ts | 6 +- .../shared/legacy-project-environment.ts | 117 +++--------------- .../legacy-project-environment.unit.test.ts | 15 +++ 3 files changed, 34 insertions(+), 104 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-dotenv.ts b/apps/cli/src/legacy/shared/legacy-dotenv.ts index 744a36b00c..eef14a1691 100644 --- a/apps/cli/src/legacy/shared/legacy-dotenv.ts +++ b/apps/cli/src/legacy/shared/legacy-dotenv.ts @@ -174,12 +174,8 @@ function extractVarValue( * Strip an unquoted inline comment, matching godotenv: scanning from the right, * a `#` preceded by whitespace begins a comment (`54323 # local` → `54323`), * while a `#` with no leading whitespace is part of the value (`foo#bar`). - * - * Exported for reuse by `legacy-project-environment.ts`'s `readDotEnvFile`, which - * needs the same whitespace-aware rule for the project-root + `SUPABASE_ENV`-selected - * dotenv files it reads (see that module's doc comment). */ -export function stripInlineComment(value: string): string { +function stripInlineComment(value: string): string { for (let i = value.length - 1; i > 0; i--) { if (value[i] === "#" && (value[i - 1] === " " || value[i - 1] === "\t")) { return value.slice(0, i); diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index 26dc6b0175..fe1ad08291 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { ProjectEnvironment } from "@supabase/config"; -import { stripInlineComment } from "./legacy-dotenv.ts"; +import { parseDotEnv } from "./legacy-dotenv.ts"; /** * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and Go's @@ -37,116 +37,35 @@ function candidateDotenvFilenames(env: string): ReadonlyArray { return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; } -// Mirrors godotenv's `expandVariables` (`godotenv@v1.5.1/parser.go:253,257-271`): substitutes -// `$VAR`/`${VAR}` using only keys already parsed earlier in *this* file (godotenv re-parses -// each dotenv file into its own fresh map — it never sees a different file's keys or the -// ambient shell env, `parser.go:20-45`). Called for unquoted and double-quoted values only -// (`parser.go:157,174-178`); single-quoted values never reach this (`parser.go:172-173`). An -// unresolved reference expands to `""` (Go's zero value for a missing map key), not the -// literal `$NAME`. -// -// A leading `\` before `$` escapes the reference: godotenv's `expandVarRegex` captures that -// backslash (`parser.go:253`), and its replacer strips ONLY the backslash and returns the rest -// of the match verbatim — `\$FOO`/`\${FOO}` becomes the literal `$FOO`/`${FOO}`, never looked up -// in the map, even when `FOO` is defined (`parser.go:264-265`: `if submatch[1] == "\\" ... return -// submatch[0][1:]`). Verified directly against the real `joho/godotenv@v1.5.1` module (the version -// `apps/cli-go/go.mod` pins) rather than reasoning from the doc comment alone. The earlier -// `\n`/`\r` unescape step in {@link readDotEnvFile} only matches backslash+`n`/`r`, so an -// escaping backslash before `$` survives untouched until this function sees it, matching Go's -// own `expandEscapes` → `expandVariables` order. -function expandDotEnvVariable(value: string, values: Readonly>): string { - return value.replace( - /(\\)?\$(?:\{([A-Z0-9_]+)\}|([A-Z0-9_]+))?/g, - ( - match, - backslash: string | undefined, - braced: string | undefined, - bare: string | undefined, - ) => { - if (backslash !== undefined) return match.slice(1); - if (braced !== undefined) return values[braced] ?? ""; - if (bare !== undefined) return values[bare] ?? ""; - return match; - }, - ); -} - /** - * Minimal `KEY=VALUE` dotenv reader, intentionally not reusing - * `@supabase/config`'s Effect-based `FileSystem` parser: this module stays a - * plain synchronous helper (like `legacy-local-config-values.ts`'s - * `loadFirstSigningKey`) since it only needs a handful of extra files read - * once per `stop`/`status` invocation. Quoting/escaping matches - * `packages/config/src/project.ts`'s `parseDotEnv` closely enough for the env - * vars this is used for (`SUPABASE_PROJECT_ID`, `SUPABASE_AUTH_*`), which - * never need the full dotenv spec (multiline values, `export` re-declares). - * - * Accepts godotenv's `KEY: VALUE` YAML-style separator as well as `KEY=VALUE` - * (`joho/godotenv@v1.5.1/parser.go:90-95`'s `locateKeyName` treats `=` and `:` - * as interchangeable) — matching `packages/config/src/project.ts`'s - * `parseDotEnv`, which already accepts both forms. + * Minimal dotenv reader for the project-root and `SUPABASE_ENV`-selected extra + * files this module resolves, intentionally not reusing `@supabase/config`'s + * Effect-based `FileSystem` parser: this module stays a plain synchronous + * helper (like `legacy-local-config-values.ts`'s `loadFirstSigningKey`) since + * it only needs a handful of extra files read once per `stop`/`status` + * invocation. Delegates to {@link parseDotEnv} — the same `godotenv`-faithful, + * cursor-based parser `bootstrap`/`legacyReadDbToml` already use — rather than + * a hand-rolled line-by-line scan, so a quoted value spanning physical lines + * (a PEM/private key) parses correctly instead of aborting on what looks like + * a malformed continuation line. * * @throws on a line that isn't blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` * assignment — matching Go's `loadEnvIfExists` (`pkg/config/config.go:1209-1234`), * which propagates `godotenv.Load`'s parse error up through `loadNestedEnv` and * fails `Config.Load` before `stop`/`status` touch Docker, rather than silently - * skipping the bad line. Mirrors `packages/config/src/project.ts`'s - * `parseDotEnv`, which already fails the same way for `supabase/.env`(.local). + * skipping the bad line. */ function readDotEnvFile(path: string): Record | undefined { if (!existsSync(path)) return undefined; const contents = readFileSync(path, "utf8"); - const values: Record = {}; - const lines = contents.split(/\r\n?|\n/); - - for (const [index, rawLine] of lines.entries()) { - const line = rawLine.trim(); - if (line === "" || line.startsWith("#")) continue; - - const match = /^(?:export\s+)?([\w.-]+)\s*(?:=|:\s+)(.*)$/.exec(line); - if (match === null) { - throw new Error(`failed to parse environment file: ${path} (line ${index + 1})`); - } - const key = match[1]; - if (key === undefined) continue; - - let value = (match[2] ?? "").trim(); - const quote = value[0]; - // A value is quoted iff it STARTS with a quote — matching godotenv's - // `extractVarValue` (`joho/godotenv@v1.5.1/parser.go:160-180`), which locates the - // quoted span by scanning forward for the first unescaped matching quote, not by - // requiring the whole (trimmed) remainder to end with one. Anything after that - // closing quote (e.g. a trailing `# comment`) is discarded, so `"demo" # local` - // parses as `demo`, not the literal `"demo"` a naive `endsWith(quote)` check would - // produce by falling through to the unquoted branch below. - let quoteEnd = -1; - if (quote === '"' || quote === "'") { - for (let i = 1; i < value.length; i++) { - if (value[i] === quote && value[i - 1] !== "\\") { - quoteEnd = i; - break; - } - } - } - if (quoteEnd !== -1) { - value = value.slice(1, quoteEnd); - if (quote === '"') { - value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); - value = expandDotEnvVariable(value, values); - } - } else { - // godotenv only starts a comment at a `#` preceded by whitespace (see - // `stripInlineComment`'s doc comment); an unquoted `#bar` with no leading - // space is part of the value, e.g. `SUPABASE_PROJECT_ID=foo#bar`. - value = stripInlineComment(value).trim(); - value = expandDotEnvVariable(value, values); - } - - values[key] = value; + try { + return parseDotEnv(contents); + } catch (cause) { + throw new Error( + `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, + ); } - - return values; } /** diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index e5f8ee4414..8e7e71b826 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -245,6 +245,21 @@ describe("legacyResolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); }); + it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { + // godotenv's parser scans the whole buffer with a cursor, not line-by-line + // (`godotenv@v1.5.1/parser.go:20-45`), so a quoted value spanning physical + // lines — e.g. a pasted PEM private key — doesn't break parsing of the rest + // of the file. A naive line-by-line reader would see the continuation line + // as malformed and abort before SUPABASE_PROJECT_ID is ever read. + const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; + writeFileSync( + join(root, ".env"), + `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + ); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); + }); + describe("when no project was found (projectEnv is null)", () => { // Go's `loadNestedEnv` runs unconditionally before `config.toml` is ever // opened (`pkg/config/config.go:786-793`), so a missing config file must From a7081ba37408b670dd22deacb2c4dbfbf32596a8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:22:37 +0100 Subject: [PATCH 46/81] fix(cli): reject auth-enabled configs with an empty site_url before status (review: #5765) Go's Config.Validate requires auth.site_url to be non-empty whenever auth.enabled is true (pkg/config/config.go:1086-1090), failing before any command renders output. @supabase/config's schema only defaults site_url when the key is absent, so an explicit site_url = "" decoded without error and status printed credentials for a config Go refuses to load. Add the same required-field check, gated on the post-override auth.enabled value, alongside the existing db.port check. --- .../shared/legacy-local-config-values.ts | 10 ++++ .../legacy-local-config-values.unit.test.ts | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index f6cb776ca0..dd1fb7bc42 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -378,6 +378,7 @@ function validateLocalApiTls( * override doesn't parse as a valid bool. * @throws when `api.tls.enabled` is set with only one of `cert_path`/`key_path`, or a * configured file can't be read — see {@link validateLocalApiTls}. + * @throws when `auth.enabled` is true and `auth.site_url` is empty. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. */ @@ -478,6 +479,15 @@ export function legacyResolveLocalConfigValues( "auth.enabled", projectEnvValues, ); + // Go's `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1086-1090`), before the signing-keys read below — + // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT + // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as + // `""` with no schema-level error, same gap as `db.port === 0` above. + const siteUrl = envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues); + if (authEnabled && (siteUrl === undefined || siteUrl.length === 0)) { + throw new Error("Missing required field in config: auth.site_url"); + } const signingKey = authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index c17042d8e9..391d8ed6c5 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -537,6 +537,55 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.site_url (required field in config)", () => { + it("rejects an explicit empty site_url when auth is enabled", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.site_url", + ); + }); + + it("does not throw when site_url is set and auth is enabled", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's `Validate` nests this check inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1086-1090`) — a disabled auth section never + // requires site_url, however empty it is. + it("does not throw an explicit empty site_url when auth is disabled", () => { + const config = baseConfig({ auth: { enabled: false, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + delete process.env["SUPABASE_AUTH_SITE_URL"]; + }); + + it("rejects an empty site_url when auth is enabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const config = baseConfig({ auth: { enabled: false, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + }); + describe("api.tls (cert/key validation)", () => { const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); From dd36f4bab05d2b194720b734013eaa9a22ab7397 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:48:30 +0100 Subject: [PATCH 47/81] fix(cli): validate db.major_version and api.port before status render (review: #5765) Go's Config.Validate rejects an unsupported db.major_version (0/12/other) and a zero api.port when api.enabled, before any Docker call (pkg/config/config.go:1006-1008,1034-1061). legacyResolveLocalConfigValues only checked db.port===0; add the matching major_version and api.port checks so status gets the same parity for both fields. --- .../shared/legacy-local-config-values.ts | 81 +++++++++++++++++- .../legacy-local-config-values.unit.test.ts | 83 +++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index dd1fb7bc42..3cc561f555 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -370,6 +370,63 @@ function validateLocalApiTls( } } +/** + * Go's supported `db.major_version` values (`pkg/config/config.go:1039-1040`, the + * `case 13, 14:`/`case 15, 17:` branches — both are no-ops in Go's `Validate`, the + * 15/17 sub-branch only rewrites `Db.Image` for OrioleDB, which `status`/`stop` + * never read). `12` and `0` get their own dedicated messages below; anything else + * falls through to the generic invalid-value message. + */ +const SUPPORTED_DB_MAJOR_VERSIONS = new Set([13, 14, 15, 17]); + +/** + * Go's `Config.Validate`'s `switch c.Db.MajorVersion` (`pkg/config/config.go: + * 1034-1061`): `0` is the zero-value/missing case, `12` has a dedicated + * unsupported-version message (with a migration-docs link), `13`/`14`/`15`/`17` + * are supported (the 15/17 OrioleDB image-rewrite sub-branch is skipped here — + * irrelevant to `status`/`stop`, which never read `db.image`), and anything else + * is the generic invalid-value message. Mirrors the equivalent check already + * ported for the `db query`/`test db` path (`legacy-db-config.toml-read.ts: + * 1414-1429`), except this one honors Go's exact `case 0:` message rather than + * folding it into the generic "Invalid db.major_version" text. + */ +function validateDbMajorVersion(majorVersion: number): void { + if (majorVersion === 0) { + throw new Error("Missing required field in config: db.major_version"); + } + if (majorVersion === 12) { + throw new Error( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + if (!SUPPORTED_DB_MAJOR_VERSIONS.has(majorVersion)) { + throw new Error(`Failed reading config: Invalid db.major_version: ${majorVersion}.`); + } +} + +/** + * `SUPABASE_DB_MAJOR_VERSION` sibling of {@link envOverridePort} for the one + * numeric field Go decodes as `uint` rather than `uint16` (`pkg/config/db.go:87`) + * — same generic Viper `AutomaticEnv` binding (`config.go:576-586`), same + * mapstructure hard-fail-on-bad-value semantics as the port/bool overrides, but + * with no upper-bound cap. A non-digit override folds into the same generic + * "Invalid db.major_version" message {@link validateDbMajorVersion} produces for + * an out-of-set numeric value, since Go's own decode failure and `Validate` + * failure for this field aren't independently distinguishable from the CLI's + * output the way ports/bools are. + */ +function envOverrideMajorVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_DB_MAJOR_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid db.major_version: ${value}.`); + } + return Number(value); +} + /** * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv @@ -378,6 +435,10 @@ function validateLocalApiTls( * override doesn't parse as a valid bool. * @throws when `api.tls.enabled` is set with only one of `cert_path`/`key_path`, or a * configured file can't be read — see {@link validateLocalApiTls}. + * @throws when `api.enabled` is true and `api.port` (post-override) is `0`. + * @throws when `db.port` (post-override) is `0`. + * @throws when `db.major_version` (post-override) is `0`, `12`, or otherwise + * unsupported — see {@link validateDbMajorVersion}. * @throws when `auth.enabled` is true and `auth.site_url` is empty. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. @@ -417,6 +478,20 @@ export function legacyResolveLocalConfigValues( envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues), ); } + // Go's `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY + // when `api.enabled` (`pkg/config/config.go:1006-1008`) — unlike `db.port` + // below, which has no `enabled` gate. Resolved once into a named const so the + // check and the URL derivation below share the same overridden value instead + // of calling `envOverridePort` twice. + const apiPort = envOverridePort( + "SUPABASE_API_PORT", + config.api.port, + "api.port", + projectEnvValues, + ); + if (apiEnabled && apiPort === 0) { + throw new Error("Missing required field in config: api.port"); + } const apiExternalUrl = legacyResolveApiExternalUrl( { external_url: envOverride( @@ -424,7 +499,7 @@ export function legacyResolveLocalConfigValues( config.api.external_url, projectEnvValues, ), - port: envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), + port: apiPort, tls: { enabled: apiTlsEnabled }, }, hostname, @@ -444,6 +519,10 @@ export function legacyResolveLocalConfigValues( if (dbPort === 0) { throw new Error("Missing required field in config: db.port"); } + // Go's `Config.Validate` checks `db.major_version` right after `db.port` + // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). + const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); + validateDbMajorVersion(majorVersion); const studioPort = envOverridePort( "SUPABASE_STUDIO_PORT", config.studio.port, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 391d8ed6c5..51e1f88b70 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -333,6 +333,89 @@ describe("legacyResolveLocalConfigValues", () => { "Missing required field in config: db.port", ); }); + + // Unlike db.port, Go gates the api.port===0 rejection on api.enabled + // (pkg/config/config.go:1006-1008) — api.enabled defaults to true, so a + // configured or env-overridden zero port is rejected by default. + it("rejects a configured api.port of 0 when api is enabled", () => { + const config = baseConfig({ api: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("db.major_version (required field in config)", () => { + afterEach(() => { + delete process.env["SUPABASE_DB_MAJOR_VERSION"]; + }); + + it("rejects a configured major_version of 0", () => { + const config = baseConfig({ db: { major_version: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: db.major_version", + ); + }); + + it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + const config = baseConfig({ db: { major_version: 12 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Postgres version 12.x is unsupported.", + ); + }); + + it.each([13, 14, 15, 17])("accepts the supported major_version %d", (majorVersion) => { + const config = baseConfig({ db: { major_version: majorVersion } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an unsupported major_version with the generic invalid-value message", () => { + const config = baseConfig({ db: { major_version: 16 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: 16.", + ); + }); + + it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(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"] = ""; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); describe("SUPABASE_API_TLS_ENABLED env override", () => { From c0faae5eaee06474278c5e624a7cf86ea6012eb3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:48:40 +0100 Subject: [PATCH 48/81] fix(cli): validate config before default stop, matching Go's flags.LoadConfig (review: #5765) Go's stop.Run calls flags.LoadConfig (config load + Validate) only in the default path (no --all/--project-id), before any Docker call (internal/stop/stop.go:15-25). The TS handler only decoded config to read project_id; reuse legacyResolveLocalConfigValues for its throwing side effects so an invalid config (bad db.major_version, api.port=0, etc.) fails stop before it touches containers, same as status already does. --- .../src/legacy/commands/stop/stop.handler.ts | 35 ++++++++++- .../commands/stop/stop.integration.test.ts | 63 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index b59f21ef53..ef61c09405 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,6 +1,6 @@ -import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, Option, Result } from "effect"; +import { Effect, Option, Result, Schema } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; @@ -20,6 +20,8 @@ import { legacyListContainersByLabel, legacyListVolumesByLabel, } from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveLocalConfigValues } from "../../shared/legacy-local-config-values.ts"; import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; import { @@ -134,9 +136,36 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // VALIDATE config before any Docker call, matching Go's `flags.LoadConfig` + // (config load + `Validate`, `internal/utils/flags/config_path.go:10-14` -> + // `pkg/config/config.go:882`), which the default `stop` path runs in full + // (`internal/stop/stop.go:15-25`) before ever touching Docker — unlike the + // `--all`/`--project-id` branches above, which bypass config loading + // entirely and so must NOT run this. `legacyResolveLocalConfigValues` is + // reused purely for its throwing side effects (its resolved URLs/keys are + // discarded); it gives `stop` the same partial-but-growing `Config.Validate` + // parity `status` already has (`status.handler.ts`), rather than a one-off + // re-implementation. `legacyGetHostname` has no Docker dependency, so it's + // safe to call speculatively here too. + yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + config, + legacyGetHostname(), + cliConfig.workdir, + projectEnvValues, + ), + catch: (cause) => + new LegacyStopConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + const resolved = legacyResolveLocalProjectId( projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], - loaded?.config.project_id, + config.project_id, cliConfig.workdir, ); return legacySanitizeProjectId(resolved); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index b14d69204b..4c35ae722a 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -632,6 +632,69 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "fails and never spawns docker when config.toml has an unsupported db.major_version", + () => { + // Matches Go's default `stop` path, which runs `flags.LoadConfig` (config + // load + `Validate`) entirely before any Docker call + // (`internal/stop/stop.go:15-25` -> `pkg/config/config.go:882`) — a config + // Go rejects must fail `stop` before it touches containers, not just when + // reading `project_id`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(JSON.stringify(exit.cause)).toContain("Postgres version 12.x is unsupported"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("does not run config Validate for --all (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: the `--all` branch never calls + // `flags.LoadConfig`, so an otherwise-invalid config.toml must not block it. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ all: Option.some(true) }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project"); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not run config Validate for --project-id (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: an explicit `--project-id` sets + // `Config.ProjectId` directly and never calls `flags.LoadConfig`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ projectId: Option.some("explicit") }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=explicit"); + }).pipe(Effect.provide(layer)); + }); + it.live("fails when stopping a container errors", () => { const { layer } = setup({ configuredProjectId: "demo", From f2a35d0abc19359e34ae627d276dc91ddf160c35 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 15:48:46 +0100 Subject: [PATCH 49/81] fix(cli): resolve relative --workdir/SUPABASE_WORKDIR to an absolute path (review: #5765) Go's ChangeWorkDir chdirs with the raw --workdir/SUPABASE_WORKDIR value, but every later reader (including Config.ProjectId's cwd-basename fallback, run via Eject on every Config.Load) reads os.Getwd(), the real absolute directory, never the raw string. resolveWorkdir returned the flag/env value verbatim, so --workdir "." left LegacyCliConfig.workdir as ".", which basenamed to an empty project id and built a bare, all-projects Docker label filter. Resolve both branches against the real process cwd. --- .../legacy/config/legacy-cli-config.layer.ts | 20 +++++++++- .../legacy-cli-config.layer.unit.test.ts | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 70ec567d30..4654d562e7 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -151,6 +151,22 @@ function resolveProfile( }); } +/** + * Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + * `os.Chdir(workdir)`s using the raw `--workdir`/`SUPABASE_WORKDIR` string, + * which can be relative (e.g. `.`) — but every later reader of the resolved + * workdir (including the `Config.ProjectId` cwd-basename default, `Eject`, + * `pkg/config/config.go:561-570`, run on every `Config.Load()` via + * `mergeDefaultValues`, `config.go:690-699`) reads `os.Getwd()`, the real + * ABSOLUTE directory, never the raw configured string. `os.Chdir(".")` is a + * no-op syscall-wise, so Go's `cwd` is unaffected by the flag/env value being + * relative. This resolves the flag/env value against the real process `cwd` + * the same way, so `LegacyCliConfig.workdir` is always absolute — matching + * Go's invariant that basename-ing it (e.g. `legacyResolveLocalProjectId`'s + * workdir-basename fallback) operates on a real directory name, not a + * relative-path fragment like `.` (which would sanitize to an empty project + * id and build a bare, all-projects-matching Docker label filter). + */ function resolveWorkdir( flagValue: Option.Option, envValue: string | undefined, @@ -160,10 +176,10 @@ function resolveWorkdir( ): Effect.Effect { return Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return flagValue.value; + return path.resolve(cwd, flagValue.value); } if (envValue !== undefined && envValue.length > 0) { - return envValue; + return path.resolve(cwd, envValue); } let current = cwd; // Walk up until we hit a directory containing supabase/config.toml or the FS root. diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 541e2d4a9b..28702a6218 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -271,6 +271,43 @@ describe("legacyCliConfigLayer", () => { ), ); + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + // `os.Chdir`s the raw flag/env value, but every later reader — including the + // `Config.ProjectId` cwd-basename default (`Eject`, `pkg/config/config.go: + // 561-570`) — reads `os.Getwd()`, the real absolute directory, never the raw + // string. A relative `--workdir .`/`SUPABASE_WORKDIR=.` must therefore resolve + // to an absolute path here too, not stay `"."` (which would later basename to + // an empty project id). + it.effect("resolves a relative --workdir flag against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot }))), + ); + + it.effect("resolves a relative --workdir flag with a subdirectory against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(join(tempRoot, "sub")); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot }))), + ); + + it.effect("resolves a relative SUPABASE_WORKDIR env value against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot }))), + ); + + it.effect("keeps an absolute --workdir flag unchanged", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/flag/workdir"); + }).pipe( + Effect.provide(makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot })), + ), + ); + it.effect("walks up from CWD looking for supabase/config.toml", () => { const projectRoot = join(tempRoot, "project"); const nested = join(projectRoot, "deep", "child"); From 622a0665a8144a390920f01733fd0a78e1a6c44b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:06:29 +0100 Subject: [PATCH 50/81] fix(cli): reject zero studio/local_smtp ports and invalid deno_version before status/stop render Go's Config.Validate rejects studio.port===0/local_smtp.port===0 when their section is enabled (pkg/config/config.go:1070-1073,1081-1083), and rejects edge_runtime.deno_version outside {1,2} unconditionally (config.go:1164-1173) - none of these were checked before status/stop render output for a config the Go CLI would refuse to load. review: #5765 --- .../shared/legacy-local-config-values.ts | 77 ++++++++++++ .../legacy-local-config-values.unit.test.ts | 114 ++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 3cc561f555..a6be5e584f 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -427,6 +427,45 @@ function envOverrideMajorVersion( return Number(value); } +/** + * Go's `Config.Validate`'s `switch c.EdgeRuntime.DenoVersion` (`pkg/config/ + * config.go:1164-1173`): `0` is the zero-value/missing case, `1`/`2` are + * supported (the `1` sub-branch only rewrites `EdgeRuntime.Image` to the + * `deno1` tag, which `status`/`stop` never read), and anything else is the + * generic invalid-value message. Unlike `studio.port`/`local_smtp.port`, this + * switch is NOT nested inside an `edge_runtime.enabled` gate — it runs + * unconditionally, so a disabled edge runtime with an invalid `deno_version` + * still fails config loading. Mirrors the equivalent check already ported for + * the `db diff`/pg-delta path (`legacy-db-config.toml-read.ts:1482-1499`). + */ +function validateDenoVersion(denoVersion: number): void { + if (denoVersion === 0) { + throw new Error("Missing required field in config: edge_runtime.deno_version"); + } + if (denoVersion !== 1 && denoVersion !== 2) { + throw new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`); + } +} + +/** + * `SUPABASE_EDGE_RUNTIME_DENO_VERSION` sibling of {@link envOverrideMajorVersion} + * — same generic Viper `AutomaticEnv` binding, same mapstructure + * hard-fail-on-bad-value semantics, no upper-bound cap. A non-digit override + * folds into the same generic "Invalid edge_runtime.deno_version" message + * {@link validateDenoVersion} produces for an out-of-set numeric value. + */ +function envOverrideDenoVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${value}.`); + } + return Number(value); +} + /** * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv @@ -439,6 +478,11 @@ function envOverrideMajorVersion( * @throws when `db.port` (post-override) is `0`. * @throws when `db.major_version` (post-override) is `0`, `12`, or otherwise * unsupported — see {@link validateDbMajorVersion}. + * @throws when `edge_runtime.deno_version` (post-override) is `0` or otherwise + * not `1`/`2` — see {@link validateDenoVersion}. Unconditional, not gated on + * `edge_runtime.enabled`. + * @throws when `studio.enabled` is true and `studio.port` (post-override) is `0`. + * @throws when `local_smtp.enabled` is true and `local_smtp.port` (post-override) is `0`. * @throws when `auth.enabled` is true and `auth.site_url` is empty. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. @@ -523,18 +567,45 @@ export function legacyResolveLocalConfigValues( // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); validateDbMajorVersion(majorVersion); + // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` + // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same + // enabled-gated pattern as `api.port` above. + const studioEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); const studioPort = envOverridePort( "SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues, ); + if (studioEnabled && studioPort === 0) { + throw new Error("Missing required field in config: studio.port"); + } + // Go's `Config.Validate` rejects `local_smtp.port === 0`/ + // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct + // field is still named `Inbucket` for the `[local_smtp]` TOML section + // (`pkg/config/config.go:235,1081-1083`), so `local_smtp.enabled` and the + // deprecated `inbucket.enabled` alias are the same underlying flag, not two + // independent ones. + const mailpitEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); const mailpitPort = envOverridePort( "SUPABASE_LOCAL_SMTP_PORT", config.local_smtp.port, "local_smtp.port", projectEnvValues, ); + if (mailpitEnabled && mailpitPort === 0) { + throw new Error("Missing required field in config: local_smtp.port"); + } const jwtSecret = resolveJwtSecret( envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), ); @@ -571,6 +642,12 @@ export function legacyResolveLocalConfigValues( authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; + // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth + // block and the functions loop (`pkg/config/config.go:1158-1173`), and — + // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no + // `edge_runtime.enabled` gate. + const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + validateDenoVersion(denoVersion); return { apiUrl: apiExternalUrl, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 51e1f88b70..899c9fdea4 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -356,6 +356,55 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ api: { enabled: false, port: 0 } }); expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); + + // Go gates the studio.port===0 rejection on studio.enabled + // (pkg/config/config.go:1070-1073), same pattern as api.port above. + // studio.enabled defaults to true, so a configured or env-overridden zero + // port is rejected by default. + it("rejects a configured studio.port of 0 when studio is enabled", () => { + const config = baseConfig({ studio: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's + // struct field is still named `Inbucket` for the `[local_smtp]` TOML + // section, pkg/config/config.go:235,1081-1083), same pattern as api.port/ + // studio.port above. local_smtp.enabled defaults to true, so a configured + // or env-overridden zero port is rejected by default. + it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { + const config = baseConfig({ local_smtp: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); describe("db.major_version (required field in config)", () => { @@ -418,6 +467,71 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + // Go's Config.Validate rejects an invalid edge_runtime.deno_version + // unconditionally — NOT gated on edge_runtime.enabled + // (pkg/config/config.go:1164-1173). + describe("edge_runtime.deno_version (required field in config)", () => { + afterEach(() => { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + }); + + it("rejects a configured deno_version of 0", () => { + const config = baseConfig({ edge_runtime: { deno_version: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: edge_runtime.deno_version", + ); + }); + + it.each([1, 2])("accepts the supported deno_version %d", (denoVersion) => { + const config = baseConfig({ edge_runtime: { deno_version: denoVersion } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an unsupported deno_version with the generic invalid-value message", () => { + const config = baseConfig({ edge_runtime: { deno_version: 3 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + }); + + it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(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"] = ""; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an invalid deno_version even when edge_runtime is disabled", () => { + const config = baseConfig({ edge_runtime: { enabled: false, deno_version: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: edge_runtime.deno_version", + ); + }); + }); + describe("SUPABASE_API_TLS_ENABLED env override", () => { // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), From c36a9e27528350b84a794cd800bc51981c8a419e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:06:38 +0100 Subject: [PATCH 51/81] fix(cli): load config.toml only for legacy status/stop, matching Go's NewPathBuilder Go's Config.Load/NewPathBuilder (apps/cli-go/pkg/config/utils.go:43-48) only ever resolves supabase/config.toml - it has no concept of a JSON project config file. loadProjectConfig prefers config.json when present, so a workdir with a stray config.json made native status/stop report ports/project ids Go would never see, and could point stop at the wrong project's containers. Add a tomlOnly option and set it for both handlers. review: #5765 --- .../legacy/commands/status/status.handler.ts | 6 +++ .../src/legacy/commands/stop/stop.handler.ts | 6 +++ packages/config/src/io.ts | 12 ++++- packages/config/src/io.unit.test.ts | 44 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 6110154c8e..3716fc420e 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -161,6 +161,12 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const loaded = yield* loadProjectConfig(cliConfig.workdir, { projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, reporting ports/keys for a config Go never reads. + tomlOnly: true, }).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ef61c09405..2380391959 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -130,6 +130,12 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject const loaded = yield* loadProjectConfig(cliConfig.workdir, { projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, potentially stopping containers for the wrong project. + tomlOnly: true, }).pipe( Effect.mapError( (cause) => diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 005527645f..2f56edcb28 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -53,6 +53,16 @@ export interface LoadProjectConfigOptions { readonly projectEnv?: ProjectEnvironment; /** See {@link FindProjectPathsOptions.search}. */ readonly search?: boolean; + /** + * Skip the `config.json`-over-`config.toml` preference below and only ever + * load `config.toml`. Go's `Config.Load`/`NewPathBuilder` + * (`apps/cli-go/pkg/config/utils.go:43-48`) has no concept of a JSON project + * config file — it always resolves `supabase/config.toml` and treats a + * missing file as defaults — so Go-parity callers (the legacy `status`/`stop` + * ports) must set this to avoid picking up a stray `config.json` that Go + * would never see. + */ + readonly tomlOnly?: boolean; } export interface SaveProjectConfigOptions { @@ -457,7 +467,7 @@ export const loadProjectConfig = Effect.fnUntraced(function* ( ? project.configPath : project.configPath.replace(/config\.json$/, "config.toml"); - if (yield* fs.exists(jsonPath)) { + if (!options?.tomlOnly && (yield* fs.exists(jsonPath))) { const json = yield* loadProjectConfigFile(jsonPath, options); return { diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index bcad76a9fc..51956fe9ce 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -323,6 +323,50 @@ major_version = 16 } }); + // Go's `NewPathBuilder`/`Config.Load` (`apps/cli-go/pkg/config/utils.go: + // 43-48`) only ever resolves `supabase/config.toml` — it has no concept of a + // JSON project config file. Go-parity callers (legacy `status`/`stop`) pass + // `tomlOnly: true` so a stray `config.json` never wins over `config.toml`. + test("loads TOML instead of JSON when tomlOnly is set, even if JSON exists", async () => { + const cwd = makeTempProject(); + const jsonPath = await runConfigEffect(configJsonPath(cwd)); + const tomlPath = await runConfigEffect(configTomlPath(cwd)); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); + await writeFile( + tomlPath, + `project_id = "toml-ref" + +[db] +major_version = 16 +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + expect(loaded?.format).toBe("toml"); + expect(loaded?.config.project_id).toBe("toml-ref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("returns null when tomlOnly is set and only JSON exists", async () => { + const cwd = makeTempProject(); + const jsonPath = await runConfigEffect(configJsonPath(cwd)); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + expect(loaded).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads TOML when JSON is absent", async () => { const cwd = makeTempProject(); const tomlPath = await runConfigEffect(configTomlPath(cwd)); From d3838ed4ea3d48145c3f2f42ba88d4640f10245f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:06:46 +0100 Subject: [PATCH 52/81] fix(config): preserve env() literal for present-but-empty vars in deferred resolution interpolateLeafValue (used by resolveProjectValue/resolveProjectSubtree for consumer-side lookups like edge_runtime.secrets) only guarded against a fully unset env var, not a present-but-empty one, unlike substituteEnvLeaf in lib/env.ts. Go's LoadEnvHook only substitutes a non-empty value (apps/cli-go/pkg/config/decode_hooks.go:19-24, `len(env) > 0`), so a dotenv line like `EMPTY=` should preserve the `env(EMPTY)` literal. Without this fix, a present-but-empty secret resolved to "", got redacted as a real value, and `secrets set` uploaded a blank secret Go would have skipped. review: #5765 --- packages/config/src/project.ts | 16 +++++++--- packages/config/src/project.unit.test.ts | 38 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 2bd5574925..4314d97f73 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -226,13 +226,21 @@ function interpolateLeafValue(value: string, env: Readonly 0`), which + // only substitutes a non-empty value — same gate as `substituteEnvLeaf` in + // `lib/env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. + // `edge_runtime.secrets.FOO = "env(EMPTY)"`) resolves to `""` here, gets + // redacted by `redactValue` as a real value instead of skipped as an + // unresolved literal, and `secrets set` uploads a blank secret Go would + // never send. + if (resolved === undefined || resolved === "") { return value; } - return env[envName] ?? value; + return resolved; } function toPathSegments(path: string): ReadonlyArray { diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index e01b0972b1..018f9f05e4 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -317,6 +317,44 @@ jwt_secret = "env(MISSING_SECRET)" } }); + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:19-24`) only + // substitutes a non-empty env var (`len(env) > 0`) — a present-but-empty + // dotenv line (`EMPTY_SECRET=`) is treated the same as an unset var, so the + // literal `env(...)` reference is preserved rather than resolved to `""`. + test("resolveProjectValue preserves env() literal when the env var is present but empty (Go parity)", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[edge_runtime.secrets] +foo = "env(EMPTY_SECRET)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "EMPTY_SECRET=\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue( + loaded!.config.edge_runtime.secrets!.foo, + projectEnv!, + "edge_runtime.secrets.foo", + ), + ); + + expect(Redacted.isRedacted(resolved)).toBe(false); + expect(resolved).toBe("env(EMPTY_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolveProjectSubtree preserves env() literals nested inside the selected subtree", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); From f97b0a12c22db5b17ecc12978ef44cedfe3b8c60 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:32:57 +0100 Subject: [PATCH 53/81] fix(cli): validate auth.captcha and analytics/bigquery config for status/stop (review: #5765) Go's Config.Validate rejects an enabled auth.captcha section missing provider/secret, and an enabled analytics.backend="bigquery" section missing gcp_project_id/gcp_project_number/gcp_jwt_path, before status or stop ever touch Docker (pkg/config/config.go:1099-1109,1174-1187). legacyResolveLocalConfigValues ported the site_url/jwt_secret/port checks from the same Validate pass but not these two, so native status/stop could succeed against a config the Go CLI refuses to load. Port both checks, matching the exact Go error wording, and add unit coverage mirroring the existing site_url/deno_version test blocks. --- .../shared/legacy-local-config-values.ts | 74 +++++++++ .../legacy-local-config-values.unit.test.ts | 144 ++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index a6be5e584f..6ba747c578 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -484,8 +484,13 @@ function envOverrideDenoVersion( * @throws when `studio.enabled` is true and `studio.port` (post-override) is `0`. * @throws when `local_smtp.enabled` is true and `local_smtp.port` (post-override) is `0`. * @throws when `auth.enabled` is true and `auth.site_url` is empty. + * @throws when `auth.enabled` is true, `auth.captcha.enabled` (post-override) is true, + * and `auth.captcha.provider` or `auth.captcha.secret` is unset. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. + * @throws when `analytics.enabled` (post-override) is true, `analytics.backend` + * (post-override) is `"bigquery"`, and `analytics.gcp_project_id`, + * `analytics.gcp_project_number`, or `analytics.gcp_jwt_path` is unset. */ export function legacyResolveLocalConfigValues( config: ProjectConfig, @@ -638,6 +643,27 @@ export function legacyResolveLocalConfigValues( if (authEnabled && (siteUrl === undefined || siteUrl.length === 0)) { throw new Error("Missing required field in config: auth.site_url"); } + // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`): an + // enabled CAPTCHA section requires both `provider` and `secret`. Read + // directly off `config.auth.captcha` (not through `envOverride`) — unlike + // the flat `auth.site_url`/`auth.signing_keys_path` fields, there's no + // established `SUPABASE_AUTH_CAPTCHA_*` env-override precedent elsewhere in + // this file for this nested optional auth sub-section. `config.auth.captcha` + // is genuinely `undefined` when `[auth.captcha]` is absent from config.toml + // (`Schema.optionalKey` in `packages/config/src/auth/index.ts`), matching + // Go's `*Captcha` pointer being nil — `?.enabled` is falsy in that case, same + // as Go's `c.Auth.Captcha != nil && c.Auth.Captcha.Enabled` guard. Go's own + // `assertEnvLoaded` warning on the secret (`config.go:1106-1108`) never fails + // validation, so it has no throwing equivalent here. + if (authEnabled && config.auth.captcha?.enabled) { + if (config.auth.captcha.provider === undefined) { + throw new Error("Missing required field in config: auth.captcha.provider"); + } + if (config.auth.captcha.secret === undefined || config.auth.captcha.secret.length === 0) { + throw new Error("Missing required field in config: auth.captcha.secret"); + } + } const signingKey = authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) @@ -649,6 +675,54 @@ export function legacyResolveLocalConfigValues( const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); validateDenoVersion(denoVersion); + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`, inside + // `func (c *config) Validate` at `config.go:989`): when `analytics.enabled` + // and `analytics.backend == "bigquery"`, all three GCP fields are required, + // checked in that order, each with its own message. Backend-enum + // validation (rejecting a non-postgres/bigquery value) is already covered + // at decode time by `@supabase/config`'s `stringEnum` + // (`packages/config/src/analytics.ts:17-41`), so it isn't reproduced here. + const analyticsEnabled = legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverride( + "SUPABASE_ANALYTICS_BACKEND", + config.analytics.backend, + projectEnvValues, + ); + if (analyticsEnabled && analyticsBackend === "bigquery") { + const gcpProjectId = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + if (gcpProjectId === undefined || gcpProjectId.length === 0) { + throw new Error("Missing required field in config: analytics.gcp_project_id"); + } + const gcpProjectNumber = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + if (gcpProjectNumber === undefined || gcpProjectNumber.length === 0) { + throw new Error("Missing required field in config: analytics.gcp_project_number"); + } + const gcpJwtPath = envOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); + if (gcpJwtPath === undefined || gcpJwtPath.length === 0) { + throw new Error( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + } + } + return { apiUrl: apiExternalUrl, restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 899c9fdea4..aa4139da50 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -532,6 +532,97 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("analytics (BigQuery backend required fields)", () => { + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order. + 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"]; + }); + + it("rejects an enabled bigquery backend without gcp_project_id", () => { + const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("rejects an enabled bigquery backend without gcp_project_number", () => { + const config = baseConfig({ + analytics: { enabled: true, backend: "bigquery", gcp_project_id: "proj" }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_number", + ); + }); + + it("rejects an enabled bigquery backend without gcp_jwt_path", () => { + const config = baseConfig({ + analytics: { + enabled: true, + backend: "bigquery", + gcp_project_id: "proj", + gcp_project_number: "123", + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + }); + + it("does not throw when an enabled bigquery backend has all three GCP fields", () => { + const config = baseConfig({ + analytics: { + enabled: true, + backend: "bigquery", + gcp_project_id: "proj", + gcp_project_number: "123", + gcp_jwt_path: "gcp.json", + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw for the postgres backend, however incomplete the GCP fields are", () => { + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw when analytics is disabled, however incomplete the GCP fields are", () => { + const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { + process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; + const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + describe("SUPABASE_API_TLS_ENABLED env override", () => { // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), @@ -783,6 +874,59 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.captcha (required fields when enabled)", () => { + // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`). + it("rejects an enabled captcha without a provider", () => { + const config = baseConfig({ + auth: { enabled: true, site_url: "http://localhost:3000", captcha: { enabled: true } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.captcha.provider", + ); + }); + + it("rejects an enabled captcha with a provider but no secret", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true, provider: "hcaptcha" }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.captcha.secret", + ); + }); + + it("does not throw when an enabled captcha has both provider and secret", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true, provider: "hcaptcha", secret: "shh" }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw when captcha is disabled, however incomplete", () => { + const config = baseConfig({ + auth: { enabled: true, site_url: "http://localhost:3000", captcha: { enabled: false } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // A disabled auth section never requires captcha fields, however + // incomplete the captcha config is. + it("does not throw an enabled captcha without provider/secret when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, captcha: { enabled: true } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + describe("api.tls (cert/key validation)", () => { const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); From 2fc16a2132eafff783247d4094f4c96c20f30a01 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:33:04 +0100 Subject: [PATCH 54/81] fix(config): accept Go's full boolean spelling set for env()-substituted booleans (review: #5765) Go's LoadEnvHook substitutes an env() reference into a raw string, and mapstructure's weakly-typed decodeBool then parses it with strconv.ParseBool, accepting 1/t/T/TRUE/true/True and 0/f/F/FALSE/false/False (plus "" as false). coerceLeaf's boolean branch only recognized the literal strings "true"/"false", so a config boolean set via e.g. `enabled = "env(FOO)"` with FOO=1 failed TS schema decoding for a config Go loads fine. Mirror the same acceptance set legacyParseGoBool already uses for SUPABASE_*_ENABLED overrides, duplicated locally so packages/config doesn't depend on apps/cli. --- packages/config/src/io.unit.test.ts | 33 +++++++++++++++++++++++++++++ packages/config/src/lib/env.ts | 14 ++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 51956fe9ce..e8bb9e6aa0 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -781,6 +781,39 @@ enabled = "env(SUPABASE_ANALYTICS_ENABLED)" } }); + test.each([ + ["1", true], + ["TRUE", true], + ["T", true], + ["True", true], + ["0", false], + ["f", false], + ["FALSE", false], + ] as const)( + "resolves env() on boolean fields using Go's strconv.ParseBool acceptance set (%s -> %s)", + async (envValue, expected) => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[analytics] +enabled = "env(SUPABASE_ANALYTICS_ENABLED)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), `SUPABASE_ANALYTICS_ENABLED=${envValue}\n`); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.analytics.enabled).toBe(expected); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + test("preserves env() literals on string fields when the var is unset (Go parity)", async () => { const cwd = makeTempProject(); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 7eef992016..fb38282721 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -65,6 +65,16 @@ export const secret = (annotations?: SecretAnnotations) => type ExpectedType = "number" | "boolean" | "string" | "unknown"; +// Go decodes an env()-substituted boolean via mapstructure's weakly-typed +// `decodeBool`, which runs `strconv.ParseBool` on the string — a wider +// acceptance set than the literal `"true"`/`"false"` this module used to +// require. Mirrors `legacyParseGoBool`'s `GO_BOOL_TRUE`/`GO_BOOL_FALSE` +// (`apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts:615-616`); +// duplicated here (not imported) so `packages/config` doesn't depend on +// `apps/cli`. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); + // Unwrap Suspend (lazy AST refs from recursive schemas). Other transformation // wrappers expose the target type via `.ast` directly, so no additional // unwrapping is needed at this layer. @@ -162,8 +172,8 @@ function coerceLeaf(value: unknown, expected: ExpectedType): unknown { return value; } if (expected === "boolean") { - if (value === "true") return true; - if (value === "false") return false; + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; return value; } return value; From 88aa0fa223ba09f7d014641e2ed30b6ef6f5809a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:33:11 +0100 Subject: [PATCH 55/81] fix(config): parse multiline quoted .env values in loadProjectEnvironment (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseDotEnv split a .env file into physical lines before matching each one against dotEnvLinePattern, so a godotenv-valid multiline quoted value (e.g. a PEM block) never got a chance to match as a single value — the continuation line(s) then failed the KEY=VALUE pattern outright. Go's loadNestedEnv goes straight to joho/godotenv, which natively supports this, so native status/stop (both of which call loadProjectEnvironment before their own Go-compatible dotenv pass) could reject a supabase/.env Go accepts, solely because of an unrelated multiline secret. Detect a line that opens a quote without closing it on that same physical line, and only then accumulate subsequent lines into the match candidate before running the existing per-line pattern — every other line continues to match exactly as before. --- packages/config/src/project.ts | 71 ++++++++++++++++++++++- packages/config/src/project.unit.test.ts | 72 +++++++++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 4314d97f73..a937bab5db 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -45,6 +45,41 @@ function normalizeAmbientEnv( return values; } +// Detects a line of the form `KEY=...` (or `KEY: ...`) whose +// quoted value does NOT close on that same physical line — the start of a +// godotenv-style multiline quoted value (e.g. a PEM block). Returns the quote +// character and the index of the opening quote within `line`, or `null` if +// the line doesn't open an unterminated quote (either no quote at all, or one +// that already closes on this line). +const dotEnvValueOpenerPattern = /^\s*(?:export\s+)?[\w.-]+(?:\s*=\s*?|:\s+?)(['"`])/; + +function findUnescapedQuoteIndex(text: string, quote: string, from: number): number { + for (let i = from; i < text.length; i += 1) { + if (text[i] === quote && text[i - 1] !== "\\") { + return i; + } + } + return -1; +} + +function detectOpenQuoteStart(line: string): { quote: string; openIndex: number } | null { + const openerMatch = dotEnvValueOpenerPattern.exec(line); + if (openerMatch === null) { + return null; + } + const quote = openerMatch[1]; + if (quote === undefined) { + return null; + } + const openIndex = openerMatch[0].length - 1; + if (findUnescapedQuoteIndex(line, quote, openIndex + 1) !== -1) { + // Already closes on this same line — this isn't the multiline case, so + // whatever made the outer match fail is a genuine parse error. + return null; + } + return { quote, openIndex }; +} + function parseDotEnvValue(rawValue: string): string { let value = rawValue.trim(); const maybeQuote = value[0]; @@ -78,7 +113,40 @@ function parseDotEnv( continue; } - const match = dotEnvLinePattern.exec(line); + let candidate = line; + let consumedThrough = index; + + // Check for an unterminated quote BEFORE attempting the single-line + // match: `dotEnvLinePattern`'s value alternatives fall back to an + // unquoted match (`[^#\r\n]+`) when none of the quoted alternatives + // close on this line, which would otherwise "succeed" with a truncated, + // still-quote-prefixed value instead of signaling a multiline value — + // masking the real bug rather than triggering accumulation. This is a + // godotenv-style quoted value spanning multiple physical lines (e.g. a + // PEM block); Go's `loadNestedEnv` parses this fine (`godotenv@v1.5.1`'s + // cursor-based scanner never splits into lines up front; see + // `legacy-dotenv.ts` for the Go-compatible reference implementation used + // elsewhere in this repo). Accumulate subsequent lines until the opened + // quote closes (or EOF), then match the same per-line pattern against + // the joined multiline chunk — its quoted-value alternatives use + // negated character classes (`[^"]` etc.), which already match embedded + // newlines once given the full span. + const opener = detectOpenQuoteStart(line); + if (opener !== null) { + for (let next = index + 1; next < lines.length; next += 1) { + const nextLine = lines[next]; + if (nextLine === undefined) { + continue; + } + candidate += "\n" + nextLine; + consumedThrough = next; + if (findUnescapedQuoteIndex(candidate, opener.quote, opener.openIndex + 1) !== -1) { + break; + } + } + } + + const match = dotEnvLinePattern.exec(candidate); if (match === null) { return yield* Effect.fail(new ProjectEnvParseError({ path, line: index + 1 })); @@ -92,6 +160,7 @@ function parseDotEnv( } values[key] = parseDotEnvValue(rawValue); + index = consumedThrough; } return values; diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index 018f9f05e4..3737f36c06 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, FileSystem, Path, Redacted } from "effect"; import { findProjectRootFor, loadProjectEnvironmentFor } from "./bun.ts"; -import { ProjectConfigParseError } from "./errors.ts"; +import { ProjectConfigParseError, ProjectEnvParseError } from "./errors.ts"; import { findProjectPaths, loadProjectConfig, @@ -142,6 +142,76 @@ describe("project discovery and lazy env resolution", () => { } }); + test("parses a multiline double-quoted .env value (godotenv/Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile( + join(cwd, "supabase", ".env"), + [ + 'PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----', + "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", + '-----END RSA PRIVATE KEY-----"', + "OTHER=value", + "", + ].join("\n"), + ); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.PRIVATE_KEY).toBe( + [ + "-----BEGIN RSA PRIVATE KEY-----", + "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + ); + expect(projectEnv?.values.OTHER).toBe("value"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("parses a multiline single-quoted .env value followed by a trailing comment", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile( + join(cwd, "supabase", ".env"), + ["MULTI='line one", "line two' # trailing comment", "AFTER=ok", ""].join("\n"), + ); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.MULTI).toBe(["line one", "line two"].join("\n")); + expect(projectEnv?.values.AFTER).toBe("ok"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("still fails a genuinely malformed .env line (not a multiline quote)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile(join(cwd, "supabase", ".env"), "!!!not-a-valid-line\n"); + + await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( + ProjectEnvParseError, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); From 1db573a35f793ca1333cc51da906c27c93838700 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 16:52:07 +0100 Subject: [PATCH 56/81] fix(config): reject duplicate remote project_id even without a projectRef (review: #5765) Go's loadFromFile builds the project_id -> [remotes.*] duplicate map unconditionally on every config load (config.go:594-602), independent of whether a remote ends up matching Config.ProjectId. loadProjectConfigFile only ran that check when a caller passed a projectRef, so native status/stop (which never select a remote) could load a config.toml with duplicate [remotes.*].project_id values that Go would reject outright. --- packages/config/src/io.ts | 82 ++++++++++++++++++----------- packages/config/src/io.unit.test.ts | 35 +++++++++++- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 2f56edcb28..73fb1d468e 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -34,11 +34,15 @@ export interface LoadedProjectConfig { } /** - * When `projectRef` is set, the matching `[remotes.]` block (the one whose - * `project_id` equals it) is merged over the base config before decode, mirroring - * Go's `config.Load` with `Config.ProjectId` set - * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base config - * verbatim, so existing callers are unaffected. + * When `projectRef` is set, the matching `[remotes.]` block (the one + * whose `project_id` equals it) is merged over the base config before decode, + * mirroring Go's `config.Load` with `Config.ProjectId` set + * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base + * config verbatim (no merge), so existing callers are unaffected — but Go's + * duplicate-`project_id` check across every `[remotes.*]` block + * (`config.go:594-602`) runs unconditionally on every config load, not only + * when a caller ends up selecting a remote, so that check always runs here + * too, even for callers that don't pass a `projectRef`. */ export interface LoadProjectConfigOptions { readonly projectRef?: string; @@ -136,28 +140,19 @@ function withDbSeedDisabled(document: Record): Record]` override whose `project_id` matches `projectRef` - * to `document`, mirroring Go's `loadFromFile` remote resolution - * (`config.go:503-518`). Returns the merged document (with `remotes` stripped) and - * the matched remote name. - * - * Like Go, duplicate `project_id`s are detected across *all* `[remotes.*]` blocks — - * not just the ones matching `projectRef` — before the matching override is applied. - * A missing `project_id` reads as `""` (Go's `viper.GetString`), so two remotes that - * both omit it collide on the empty key and fail just as in Go. + * Builds a `project_id -> "[remotes.]"` map across every `[remotes.*]` + * block, failing on the first duplicate. Mirrors Go's `loadFromFile` + * (`config.go:594-602`): that loop runs unconditionally on every config load, + * regardless of whether any remote's `project_id` ends up matching + * `Config.ProjectId` — so this always runs too, even for callers that don't + * request a specific `projectRef`. A missing `project_id` reads as `""` + * (Go's `viper.GetString`), so two remotes that both omit it collide on the + * empty key and fail just as in Go. */ -const applyRemoteOverride = Effect.fnUntraced(function* ( - document: Record, - projectRef: string, +const checkDuplicateRemoteProjectIds = Effect.fnUntraced(function* ( + remotes: Record, ) { - const remotes = document["remotes"]; - if (!isObject(remotes)) { - return { document, appliedRemote: undefined as string | undefined }; - } - // Build a project_id -> "[remotes.]" map over every remote, failing on the - // first duplicate, then resolve the single block matching projectRef. const idToName = new Map(); - let name: string | undefined; for (const [remoteName, remote] of Object.entries(remotes)) { const projectId = isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; @@ -168,10 +163,32 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( }); } idToName.set(projectId, `[remotes.${remoteName}]`); - if (projectId === projectRef) { - name = remoteName; - } } +}); + +/** + * Applies the `[remotes.]` override whose `project_id` matches `projectRef` + * to `document`, mirroring Go's `loadFromFile` remote resolution + * (`config.go:503-518`). Returns the merged document (with `remotes` stripped) and + * the matched remote name. `projectRef` of `undefined` never matches any remote + * (including one that itself omits `project_id`, which reads as `""`) — callers + * that don't request a specific remote get the duplicate check below without + * the merge, so the base document loads verbatim as before. + */ +const applyRemoteOverride = Effect.fnUntraced(function* ( + document: Record, + projectRef: string | undefined, +) { + const remotes = document["remotes"]; + if (!isObject(remotes)) { + return { document, appliedRemote: undefined as string | undefined }; + } + yield* checkDuplicateRemoteProjectIds(remotes); + const name = Object.entries(remotes).find(([, remote]) => { + const projectId = + isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; + return projectRef !== undefined && projectId === projectRef; + })?.[0]; if (name === undefined) { return { document, appliedRemote: undefined as string | undefined }; } @@ -426,12 +443,15 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( ); // Merge the matching `[remotes.*]` override over the base document before - // decode (Go's `loadFromFile` with `Config.ProjectId` set). Only requested - // when a `projectRef` is supplied, so other callers load the base verbatim. + // decode (Go's `loadFromFile` with `Config.ProjectId` set). Runs + // unconditionally so the duplicate-`project_id` check always applies, like + // Go's own loop (`config.go:594-602`), even for callers that don't request + // a `projectRef` — those just never match a remote, so the base document + // still loads verbatim, only now with the duplicate check applied too. let documentForDecode: unknown = interpolated; let appliedRemote: string | undefined; - if (options?.projectRef !== undefined && isObject(interpolated)) { - const resolved = yield* applyRemoteOverride(interpolated, options.projectRef); + if (isObject(interpolated)) { + const resolved = yield* applyRemoteOverride(interpolated, options?.projectRef); documentForDecode = resolved.document; appliedRemote = resolved.appliedRemote; } diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index e8bb9e6aa0..919c05e651 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -982,7 +982,10 @@ enabled = false } }); - test("does not merge remotes when no projectRef is requested", async () => { + test("does not merge remotes when no projectRef is requested and none has an empty project_id", async () => { + // `projectRef` defaults to "" (Go's own `Config.ProjectId` default for + // commands with no `--project-ref` flag), so this only stays unmerged + // because neither remote's `project_id` is empty. const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { const loaded = await runConfigEffect(loadProjectConfig(cwd)); @@ -994,6 +997,36 @@ enabled = false } }); + test("rejects duplicate project_id across remotes even when no projectRef is requested", async () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // status/stop (internal/utils/flags/config_path.go:11) never bind a + // `--project-ref` flag, so they hit this check with `Config.ProjectId == ""`, + // and it must still fail on a config-wide duplicate. + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`); + try { + const message = await Effect.runPromise( + loadProjectConfig(cwd).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => + Effect.succeed(error.message), + ), + Effect.provide(BunServices.layer), + ), + ); + expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("rejects duplicate project_id across remotes with Go's message", async () => { const cwd = await writeTomlProject(`project_id = "baseref" From 5262a1fc5ec2bd757e73bcd2c02a78f8f751e835 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 17:20:52 +0100 Subject: [PATCH 57/81] fix(config): reject malformed remote project refs, matching Go's Config.Validate (review: #5765) Go's Config.Validate checks every [remotes.*].project_id against a 20-lowercase-letter ref pattern unconditionally on every config load (config.go:996-1001), regardless of whether that remote is selected. The shared @supabase/config loader never validated this, so native status/stop could render or prune for a config Go would refuse to load. Test fixtures using short human-readable fake refs (previewref, override-project, etc.) are updated to valid 20-letter refs to satisfy the new check. --- .../functions/serve/serve.integration.test.ts | 14 ++--- packages/config/src/errors.ts | 12 ++++ packages/config/src/index.ts | 1 + packages/config/src/io.ts | 29 ++++++++- packages/config/src/io.unit.test.ts | 59 +++++++++++++++---- packages/config/src/project.unit.test.ts | 3 + 6 files changed, 97 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 370af5aacf..8e3354fe2d 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1695,7 +1695,7 @@ describe("legacy functions serve integration", () => { "verify_jwt = true", "", "[remotes.override]", - 'project_id = "override-project"', + 'project_id = "overrideprojectaaaaa"', "", "[remotes.override.functions.hello]", "verify_jwt = false", @@ -1710,7 +1710,7 @@ describe("legacy functions serve integration", () => { const { layer } = setupServe({ childSpawner, - projectId: Option.some("override-project"), + projectId: Option.some("overrideprojectaaaaa"), }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), @@ -1724,20 +1724,20 @@ describe("legacy functions serve integration", () => { expect(deployMockState.volumeCalls).toEqual([ { - volumeName: "supabase_edge_runtime_override-project", - projectId: "override-project", + volumeName: "supabase_edge_runtime_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.networkCalls).toEqual([ { - networkMode: "supabase_network_override-project", - projectId: "override-project", + networkMode: "supabase_network_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.runCalls).toContainEqual( expect.objectContaining({ command: "docker", - args: ["container", "inspect", "supabase_db_override-project"], + args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], }), ); diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index a7d1e82b75..ab04ebdbcc 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -29,3 +29,15 @@ export class DuplicateRemoteProjectIdError extends Data.TaggedError( )<{ readonly message: string; }> {} + +/** + * A `[remotes.]` block's `project_id` is not a valid 20-lowercase-letter + * project ref. Mirrors Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go: + * 558,996-1001`), which checks every remote's `project_id` against `refPattern` + * on every config load — regardless of whether that remote ends up selected — + * so this fails before Docker/API access, same as Go. `message` matches the Go + * string verbatim so callers can surface it without rewrapping. + */ +export class InvalidRemoteProjectIdError extends Data.TaggedError("InvalidRemoteProjectIdError")<{ + readonly message: string; +}> {} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e9a8eed763..319689810b 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,7 @@ export { ProjectConfigSchema, type ProjectConfig, type ProjectConfigJson } from "./base.ts"; export { DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, MissingProjectConfigValueError, ProjectConfigParseError, ProjectEnvParseError, diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 73fb1d468e..527df0bdb2 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -1,7 +1,11 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { ProjectConfigSchema, type ProjectConfig } from "./base.ts"; -import { DuplicateRemoteProjectIdError, ProjectConfigParseError } from "./errors.ts"; +import { + DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, + ProjectConfigParseError, +} from "./errors.ts"; import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts"; import { findProjectPaths } from "./paths.ts"; import { loadProjectEnvironment, type ProjectEnvironment } from "./project.ts"; @@ -166,6 +170,28 @@ const checkDuplicateRemoteProjectIds = Effect.fnUntraced(function* ( } }); +/** Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:558`): exactly 20 + * lowercase ASCII letters. */ +const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; + +/** + * Rejects the first `[remotes.*]` block whose `project_id` is not a valid + * project ref, mirroring Go's `Config.Validate` (`config.go:996-1001`) — that + * loop runs unconditionally over every remote on every config load, not only + * the one that ends up selected/merged, so this always runs too. + */ +const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record) { + for (const [remoteName, remote] of Object.entries(remotes)) { + const projectId = + isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; + if (!REMOTE_PROJECT_ID_PATTERN.test(projectId)) { + return yield* new InvalidRemoteProjectIdError({ + message: `Invalid config for remotes.${remoteName}.project_id. Must be like: abcdefghijklmnopqrst`, + }); + } + } +}); + /** * Applies the `[remotes.]` override whose `project_id` matches `projectRef` * to `document`, mirroring Go's `loadFromFile` remote resolution @@ -184,6 +210,7 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( return { document, appliedRemote: undefined as string | undefined }; } yield* checkDuplicateRemoteProjectIds(remotes); + yield* checkRemoteProjectIdFormat(remotes); const name = Object.entries(remotes).find(([, remote]) => { const projectId = isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 919c05e651..7965461b36 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -925,6 +925,14 @@ describe("config io [remotes.*] merge", () => { return cwd; } + // Remote `project_id`s below are valid 20-lowercase-letter refs (Go's + // `refPattern`, `config.go:558`) — `Config.Validate` rejects every + // `[remotes.*].project_id` against that pattern unconditionally on every + // config load (`config.go:996-1001`), so test fixtures must satisfy it too, + // even for scenarios that don't care about the ref's specific value. + const PREVIEW_REF = "previewrefaaaaaaaaaa"; + const STAGING_REF = "stagingrefaaaaaaaaaa"; + const BASE_WITH_REMOTES = `project_id = "baseref" [api] @@ -936,13 +944,13 @@ max_rows = 123 major_version = 15 [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] schemas = ["remote_only"] max_rows = 999 [remotes.staging] -project_id = "stagingref" +project_id = "${STAGING_REF}" [remotes.staging.api] enabled = false `; @@ -950,10 +958,10 @@ enabled = false test("merges the matching remote subtree over the base before decode", async () => { const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.appliedRemote).toBe("preview"); // remote block's project_id overrides the base - expect(loaded!.config.project_id).toBe("previewref"); + expect(loaded!.config.project_id).toBe(PREVIEW_REF); // remote scalar wins expect(loaded!.config.api.max_rows).toBe(999); // array replaced wholesale (not element-merged) @@ -1109,16 +1117,41 @@ max_rows = 2 } }); + test("rejects a remote project_id that is not a valid 20-letter ref, even with no projectRef requested", async () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only the one + // that ends up selected — so this must fail closed before status/stop reach + // Docker, exactly like Go, even when the caller never selects a remote. + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`); + try { + const message = await Effect.runPromise( + loadProjectConfig(cwd).pipe( + Effect.catchTag("InvalidRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), + ), + ); + expect(message).toBe( + "Invalid config for remotes.bad.project_id. Must be like: abcdefghijklmnopqrst", + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("the merged document carries pointer sections introduced by the remote", async () => { const cwd = await writeTomlProject(`project_id = "baseref" [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.db.ssl_enforcement] enabled = true `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); // `legacyPresenceIn` reads `document` to detect optional pointer sections; // a remote-introduced `db.ssl_enforcement` must be present there. const db = loaded!.document?.db; @@ -1135,12 +1168,12 @@ enabled = true enabled = true [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = 5 `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.db.seed.enabled).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); @@ -1151,12 +1184,12 @@ max_rows = 5 const cwd = await writeTomlProject(`project_id = "baseref" [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.db.seed] enabled = true `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.db.seed.enabled).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); @@ -1194,12 +1227,12 @@ enabled = true max_rows = 1 [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.api.max_rows).toBe(777); } finally { if (previous === undefined) { @@ -1309,7 +1342,7 @@ port = 22222 `project_id = "abc123" [remotes.staging] -project_id = "stagingref" +project_id = "stagingrefaaaaaaaaaa" [remotes.staging.inbucket] enabled = true diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index 3737f36c06..90a584ae10 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -319,6 +319,9 @@ jwt_secret = "env(AUTH_JWT_SECRET)" [edge_runtime.secrets] api_key = "env(EDGE_API_KEY)" +[remotes.preview] +project_id = "previewrefaaaaaaaaaa" + [remotes.preview.auth] jwt_secret = "env(PREVIEW_JWT_SECRET)" `, From 39d5a3b6226b29f89c2231eab6c2f9cbe3fb87c7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 17:21:09 +0100 Subject: [PATCH 58/81] fix(cli): reject an explicit --workdir/SUPABASE_WORKDIR that isn't a directory (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's ChangeWorkDir os.Chdir()s the resolved workdir in PersistentPreRunE, before any command runs (apps/cli-go/internal/utils/misc.go:231-250) — a missing path or a path that isn't a directory fails immediately, before config load or Docker access. status/stop previously only resolved the string and continued, so stop could fall through to the workdir-basename default and prune the wrong project, and status would surface a Docker/ config error instead of the workdir error. Scoped to status/stop's own handlers (legacy-workdir-validation.ts) rather than the shared legacy-cli-config.layer.ts: the default walk-up-for-config resolution always returns a real, already-existing directory, so only the explicit branch can ever fail this check, and validating it centrally in the config layer used by every legacy command would ripple the new error type into unrelated command families (db config, management API runtime) far beyond this review's scope. --- .../legacy/commands/status/status.errors.ts | 12 +++++ .../legacy/commands/status/status.handler.ts | 32 ++++++++---- .../status/status.integration.test.ts | 37 +++++++++++++ .../src/legacy/commands/stop/stop.errors.ts | 12 +++++ .../src/legacy/commands/stop/stop.handler.ts | 15 +++++- .../commands/stop/stop.integration.test.ts | 38 ++++++++++++++ .../shared/legacy-workdir-validation.ts | 52 +++++++++++++++++++ 7 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-workdir-validation.ts diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts index 33da3e92f6..9e72e14fbb 100644 --- a/apps/cli/src/legacy/commands/status/status.errors.ts +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -1,5 +1,17 @@ import { Data } from "effect"; +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `status`'s own `PreRunE` + * (override-name parsing) or `RunE`, so a bad explicit workdir must fail here + * first, before config load or any Docker access. + */ +export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ + readonly message: string; +}> {} + /** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 3716fc420e..88cbf4b949 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,6 +1,6 @@ import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, Option, Schema } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; @@ -26,6 +26,7 @@ import { } from "../../shared/legacy-go-output.encoders.ts"; import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; import { LegacyStatusConfigLoadError, @@ -35,6 +36,7 @@ import { LegacyStatusInvalidConfigError, LegacyStatusListError, LegacyStatusOverrideParseError, + LegacyStatusWorkdirError, } from "./status.errors.ts"; import { legacyRenderStatusPretty } from "./status.pretty.ts"; import { @@ -92,17 +94,27 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { - // 1. `--override-name KEY=VALUE` parsing, FIRST — mirroring Go's Cobra - // wiring, where override validation runs in `PreRunE` (`cmd/status.go: - // 21-27`) and Cobra's execute loop returns as soon as `PreRunE` errors, - // never calling `RunE` (`spf13/cobra@v1.10.2/command.go:999-1015`). So a - // malformed `--override-name` entry fails before `status.Run` ever loads - // config or touches Docker (`internal/status/status.go:101-116`) — it - // must win over a config-load error or a Docker/DB health-check error, - // not be masked by either. `overrides` itself is only consumed much - // later, by `legacyStatusValuesFromState` below. + // 0. Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before `status`'s own + // `PreRunE` (override-name parsing) or `RunE`. A missing or non-directory + // path fails immediately, so this must win over every later error. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStatusWorkdirError({ message: error.message })), + ); + + // 1. `--override-name KEY=VALUE` parsing — mirroring Go's Cobra wiring, + // where override validation runs in `PreRunE` (`cmd/status.go:21-27`) and + // Cobra's execute loop returns as soon as `PreRunE` errors, never calling + // `RunE` (`spf13/cobra@v1.10.2/command.go:999-1015`). So a malformed + // `--override-name` entry fails before `status.Run` ever loads config or + // touches Docker (`internal/status/status.go:101-116`) — it must win over + // a config-load error or a Docker/DB health-check error, not be masked by + // either. `overrides` itself is only consumed much later, by + // `legacyStatusValuesFromState` below. const overrides = yield* parseOverrides(flags.overrideName); // 2. `status` always needs config, unlike `stop` (status.go:99-103). An diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 0c88abb2a8..ebfd1b7f26 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -307,6 +307,43 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before config + // load or any Docker call — a missing path must fail immediately, not + // fall through to the workdir-basename default and inspect Docker. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { // Go's Config.Validate rejects this at config-load time // (pkg/config/apikeys.go:45-47), entirely before assertContainerHealthy/ diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts index 273a01c67e..ac8d49db97 100644 --- a/apps/cli/src/legacy/commands/stop/stop.errors.ts +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -1,5 +1,17 @@ import { Data } from "effect"; +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `stop`'s own flag validation or + * `RunE`, so a bad explicit workdir must fail here first, before config load + * or any Docker access. + */ +export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ + readonly message: string; +}> {} + /** * `--project-id` and `--all` were both set. Best-effort match of cobra's * `MarkFlagsMutuallyExclusive` message shape (`stopCmd.MarkFlagsMutuallyExclusive("project-id", diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 2380391959..f4df80d3aa 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,6 +1,6 @@ import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, Option, Result, Schema } from "effect"; +import { Effect, FileSystem, Option, Result, Schema } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; @@ -23,6 +23,7 @@ import { import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; import { legacyResolveLocalConfigValues } from "../../shared/legacy-local-config-values.ts"; import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; import { LegacyStopConfigLoadError, @@ -32,6 +33,7 @@ import { LegacyStopMutuallyExclusiveError, LegacyStopNetworkPruneError, LegacyStopVolumePruneError, + LegacyStopWorkdirError, } from "./stop.errors.ts"; /** @@ -183,8 +185,19 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before any of `stop`'s + // own flag validation or `RunE`. A missing or non-directory path fails + // immediately, so this must win over every later error, including the + // `--project-id`/`--all` mutual-exclusivity check below. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStopWorkdirError({ message: error.message })), + ); + // Presence-based, matching Cobra's `Changed` check (see the doc comment on // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` // must reject too, not just `--all`/`--all=true`. diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 4c35ae722a..9229dea1fc 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -477,6 +477,44 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before any of + // `stop`'s own flag validation, config load, or Docker access — a missing + // path must fail immediately, not fall through to the workdir-basename + // default and prune under that name. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts new file mode 100644 index 0000000000..40e8a5976f --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts @@ -0,0 +1,52 @@ +import { Data, Effect, FileSystem } from "effect"; + +/** + * Raised by {@link legacyValidateWorkdirIsDirectory} when the target path + * doesn't exist or isn't a directory. Callers map this into their own + * command-specific error type. + */ +export class LegacyWorkdirValidationError extends Data.TaggedError("LegacyWorkdirValidationError")<{ + readonly message: string; +}> {} + +/** + * Validates that `workdir` exists and is a directory, the way Go's + * `ChangeWorkDir` implicitly does via `os.Chdir` (`apps/cli-go/internal/utils/ + * misc.go:231-250`, called from `PersistentPreRunE`, `apps/cli-go/cmd/root.go: + * 93-105`, before any command runs): a missing path or a path that isn't a + * directory fails immediately, before config load or any Docker/API access. + * + * Callers that resolve `workdir` via `LegacyCliConfig` only need this check + * when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — `legacy-cli-config. + * layer.ts`'s default walk-up-for-`supabase/config.toml` resolution always + * returns a real, already-existing directory (either one containing + * `supabase/config.toml`, or the process's own `cwd`), so it can never fail + * this check; calling it unconditionally is therefore safe and simpler than + * threading "was this explicit?" through every caller. + */ +export function legacyValidateWorkdirIsDirectory( + workdir: string, + fs: FileSystem.FileSystem, +): Effect.Effect { + return fs.stat(workdir).pipe( + Effect.matchEffect({ + onFailure: (error) => { + const reason = + error.reason._tag === "NotFound" ? "no such file or directory" : error.message; + return Effect.fail( + new LegacyWorkdirValidationError({ + message: `failed to change workdir: chdir ${workdir}: ${reason}`, + }), + ); + }, + onSuccess: (info) => + info.type === "Directory" + ? Effect.void + : Effect.fail( + new LegacyWorkdirValidationError({ + message: `failed to change workdir: chdir ${workdir}: not a directory`, + }), + ), + }), + ); +} From a4aa01719b8ce370515bd3ea5f32c11664c83533 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 17:21:21 +0100 Subject: [PATCH 59/81] fix(cli): reject an explicit empty project_id, matching Go's Config.Validate (review: #5765) Go's Config.Validate checks ProjectId first, before any other field (pkg/config/config.go:990-991). The workdir-basename default is merged in as a viper default before config.toml is merged, so an explicit project_id = "" in the file overwrites that default with the literal empty string rather than being treated as absent. legacyResolveLocalProjectId previously collapsed "absent" and "present but empty" into the same fallback, so status and stop's default path could inspect or stop a different project (the workdir basename) instead of rejecting the invalid config. SUPABASE_PROJECT_ID still takes precedence, matching Viper's AutomaticEnv ordering. --- .../shared/legacy-local-config-values.ts | 22 ++++++++++++++ .../legacy-local-config-values.unit.test.ts | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 6ba747c578..40b79c0dcb 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -467,6 +467,16 @@ function envOverrideDenoVersion( } /** + * @throws when `project_id` (post-override) is an explicit empty string. Go's + * `Config.Validate` checks this FIRST, before every other field + * (`pkg/config/config.go:990-991`): the workdir-basename default is merged in + * as a viper default value BEFORE `config.toml` is merged + * (`mergeDefaultValues`/`mergeFileConfig`, `config.go:587-593,690-699`), so an + * explicit `project_id = ""` in the file overwrites that default with the + * literal empty string rather than being treated as absent — Go fails outright + * rather than falling back to the basename. A genuinely absent key decodes to + * `undefined` (not `""`, see `packages/config/src/base.ts`'s `optionalKey`), + * so it never trips this check. * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv * override doesn't parse as a valid port. @@ -498,6 +508,18 @@ export function legacyResolveLocalConfigValues( workdir: string, projectEnvValues: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { + // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field + // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above + // for why an explicit `project_id = ""` fails here while an absent key does + // not. `SUPABASE_PROJECT_ID` is checked via the same `envOverride` precedence + // every other field here uses, since Viper's `AutomaticEnv` binds it too + // (`config.go:529-535`) and it can turn an explicit-empty file value back + // into a valid override. + const resolvedProjectId = envOverride("SUPABASE_PROJECT_ID", config.project_id, projectEnvValues); + if (resolvedProjectId !== undefined && resolvedProjectId.length === 0) { + throw new Error("Missing required field in config: project_id"); + } + // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index aa4139da50..a7b0d19efe 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -133,6 +133,35 @@ describe("legacyResolveLocalConfigValues", () => { ); }); + it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { + // Go's Config.Validate checks ProjectId first, before any other field + // (pkg/config/config.go:990-991). The workdir-basename default is merged + // in as a viper default BEFORE config.toml is merged, so an explicit + // `project_id = ""` in the file overwrites that default with the literal + // empty string rather than being treated as absent — Go fails outright. + const config = baseConfig({ project_id: "" }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: project_id", + ); + }); + + it("does not reject an absent project_id (the workdir-basename default applies elsewhere)", () => { + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { + // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence + // than config.toml (config.go:529-535), so a non-empty env override must + // win even when the file's project_id is explicitly empty. + const config = baseConfig({ project_id: "" }); + expect(() => + legacyResolveLocalConfigValues(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 = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); From a0e5cb6242e6f9e24e418639941d4825cde7ee6d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 18:16:17 +0100 Subject: [PATCH 60/81] fix(cli): port missing Config.Validate checks to status/stop resolver (review: #5765) Go's Config.Validate runs several checks legacyResolveLocalConfigValues skipped, so native status/default stop could succeed on configs the Go CLI rejects: ValidateBucketName over storage.buckets (config.go:1063-1068, ungated), Auth.Hook.validate()'s URI/secret rules (config.go:1136,1453-1521), Auth.MFA.validate()'s enroll-implies-verify rule for TOTP/phone/WebAuthn (config.go:1139,1523-1534), and ValidateFunctionSlug over functions (config.go:1158-1163, ungated). The bucket-name/function-slug patterns and hook-secret pattern are hoisted from legacy-db-config.toml-read.ts (now exported) instead of duplicating them a third time. --- .../shared/legacy-db-config.toml-read.ts | 18 +- .../shared/legacy-local-config-values.ts | 149 +++++++++++- .../legacy-local-config-values.unit.test.ts | 220 ++++++++++++++++++ 3 files changed, 380 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 9664bca093..a3e34d1b16 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -344,14 +344,18 @@ const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; // `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key // during config load (`config.go:898-903`), aborting before any db command when a // name does not match. The source string is reused verbatim in the error message via -// `.source` so it byte-matches Go's `bucketNamePattern.String()`. -const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; +// `.source` so it byte-matches Go's `bucketNamePattern.String()`. Exported: also used +// by `legacy-local-config-values.ts`'s `status`/`stop` resolver, which needs the +// identical unconditional check Go's `Config.Validate` runs — hoisted rather than +// duplicated per this package's "hoist before you duplicate" rule. +export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; // Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` // runs `ValidateFunctionSlug` over every `[functions.*]` key during config load // (`config.go:993-998`), rejecting the config before any db command. `.source` is reused -// in the message so it byte-matches Go's `funcSlugPattern.String()`. -const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; +// in the message so it byte-matches Go's `funcSlugPattern.String()`. Exported for the +// same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above. +export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; /** * Go's `config.Validate` rejects any `[remotes.]` whose `project_id` is not a @@ -769,8 +773,10 @@ const legacyAssertDecryptableSecrets = ( // Go merges the template default before Validate (`templates/config.toml`), so an absent // `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; -// Go's `hookSecretPattern` (`pkg/config/config.go:1436`). -const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; +// Go's `hookSecretPattern` (`pkg/config/config.go:1436`). Exported: also used by +// `legacy-local-config-values.ts`'s `status`/`stop` resolver — hoisted rather than +// duplicated per this package's "hoist before you duplicate" rule. +export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; // Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). const LEGACY_CLERK_DOMAIN_PATTERN = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 40b79c0dcb..3073fafea7 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -6,7 +6,12 @@ import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supa import { Schema } from "effect"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; -import { legacyParseGoBool } from "./legacy-db-config.toml-read.ts"; +import { + LEGACY_BUCKET_NAME_PATTERN, + LEGACY_FUNCTION_SLUG_PATTERN, + LEGACY_HOOK_SECRET_PATTERN, + legacyParseGoBool, +} from "./legacy-db-config.toml-read.ts"; import { legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, @@ -379,6 +384,124 @@ function validateLocalApiTls( */ const SUPPORTED_DB_MAJOR_VERSIONS = new Set([13, 14, 15, 17]); +/** + * Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` + * key right after the `db.major_version` switch, unconditionally + * (`pkg/config/config.go:1063-1068`) — unlike `studio.port`/`local_smtp.port` below, + * there is no `storage.enabled`-style gate to check first. Reuses the pattern/message + * already ported for the `db`/migration config-load path + * (`legacy-db-config.toml-read.ts`) and `seed buckets` + * (`commands/seed/buckets/buckets.handler.ts`) rather than duplicating them. + */ +function validateStorageBucketNames(buckets: ProjectConfig["storage"]["buckets"]): void { + if (buckets === undefined) return; + for (const name of Object.keys(buckets)) { + if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { + throw new Error( + `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, + ); + } + } +} + +/** + * Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` key + * right after the auth block/`generateAPIKeys`, before `edge_runtime.deno_version` + * (`pkg/config/config.go:1155-1163`), unconditionally — not gated on `auth.enabled`. + * `@supabase/config`'s own `functions` schema key pattern + * (`packages/config/src/functions.ts`, `/^[a-zA-Z0-9_-]+$/`) is looser than Go's (it + * allows a digit-leading slug Go rejects), so this isn't redundant with decode-time + * validation. Reuses the pattern/message already ported for the `db`/migration + * config-load path (`legacy-db-config.toml-read.ts`). + */ +function validateFunctionSlugs(functions: ProjectConfig["functions"]): void { + for (const name of Object.keys(functions)) { + if (!LEGACY_FUNCTION_SLUG_PATTERN.test(name)) { + throw new Error( + `Invalid Function name: ${name}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, + ); + } + } +} + +/** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`). */ +const LEGACY_HOOK_TYPES = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", +] as const; + +/** + * Go's `(h *hook) validate()` / `(h *hookConfig) validate(hookType)` + * (`pkg/config/config.go:1453-1521`), called from `Config.Validate` right after the + * signing-keys/passkey checks and before `Auth.MFA.validate()` — all inside + * `if c.Auth.Enabled` (`config.go:1136-1139`). Each enabled hook requires a `uri`; + * an http(s) scheme requires non-empty `secrets` matching Go's `hookSecretPattern` + * (one or more `|`-separated values), while `pg-functions` forbids secrets outright + * and any other scheme is rejected. Scheme extraction mirrors Go's lenient + * `net/url.Parse` (which, unlike `new URL`, doesn't throw on a schemeless URI). + * Reuses the secret pattern already ported for the `db`/migration config-load path + * (`legacy-db-config.toml-read.ts`) rather than duplicating it. + */ +function validateAuthHooks(hook: ProjectConfig["auth"]["hook"]): void { + for (const hookType of LEGACY_HOOK_TYPES) { + const h = hook[hookType]; + if (!h.enabled) continue; + if (h.uri === undefined || h.uri.length === 0) { + throw new Error(`Missing required field in config: auth.hook.${hookType}.uri`); + } + const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(h.uri)?.[1] ?? "").toLowerCase(); + if (scheme === "http" || scheme === "https") { + if (h.secrets === undefined || h.secrets.length === 0) { + throw new Error(`Missing required field in config: auth.hook.${hookType}.secrets`); + } + for (const secret of h.secrets.split("|")) { + if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) { + throw new Error( + `Invalid hook config: auth.hook.${hookType}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, + ); + } + } + } else if (scheme === "pg-functions") { + if (h.secrets !== undefined && h.secrets.length > 0) { + throw new Error( + `Invalid hook config: auth.hook.${hookType}.secrets is unsupported for pg-functions URI`, + ); + } + } else { + throw new Error( + `Invalid hook config: auth.hook.${hookType}.uri should be a HTTP, HTTPS, or pg-functions URI`, + ); + } + } +} + +/** + * Go's `(m *mfa) validate()` (`pkg/config/config.go:1523-1534`), called from + * `Config.Validate` right after `Auth.Hook.validate()` and before + * `Auth.Email`/`Auth.Sms`/`Auth.External`/`Auth.ThirdParty` (`config.go:1139`) — all + * inside `if c.Auth.Enabled`. Each of the three MFA factors independently requires + * `verify_enabled` whenever `enroll_enabled` is set; `@supabase/config`'s `mfa` + * schema (`packages/config/src/auth/mfa.ts`) has no cross-field refinement, so + * nothing catches this at decode time either. + */ +function validateMfaConfig(mfa: ProjectConfig["auth"]["mfa"]): void { + if (mfa.totp.enroll_enabled && !mfa.totp.verify_enabled) { + throw new Error("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled"); + } + if (mfa.phone.enroll_enabled && !mfa.phone.verify_enabled) { + throw new Error("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled"); + } + if (mfa.web_authn.enroll_enabled && !mfa.web_authn.verify_enabled) { + throw new Error( + "Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled", + ); + } +} + /** * Go's `Config.Validate`'s `switch c.Db.MajorVersion` (`pkg/config/config.go: * 1034-1061`): `0` is the zero-value/missing case, `12` has a dedicated @@ -488,6 +611,14 @@ function envOverrideDenoVersion( * @throws when `db.port` (post-override) is `0`. * @throws when `db.major_version` (post-override) is `0`, `12`, or otherwise * unsupported — see {@link validateDbMajorVersion}. + * @throws when a `[storage.buckets.*]` key doesn't match Go's bucket-name pattern — + * see {@link validateStorageBucketNames}. Unconditional, no `storage.enabled` gate. + * @throws when `auth.enabled` is true and an enabled `[auth.hook.*]` entry's `uri` + * or `secrets` fails Go's scheme/secret-pattern rules — see {@link validateAuthHooks}. + * @throws when `auth.enabled` is true and an MFA factor's `enroll_enabled` is set + * without `verify_enabled` — see {@link validateMfaConfig}. + * @throws when a `[functions.*]` key doesn't match Go's function-slug pattern — + * see {@link validateFunctionSlugs}. Unconditional, not gated on `auth.enabled`. * @throws when `edge_runtime.deno_version` (post-override) is `0` or otherwise * not `1`/`2` — see {@link validateDenoVersion}. Unconditional, not gated on * `edge_runtime.enabled`. @@ -594,6 +725,10 @@ export function legacyResolveLocalConfigValues( // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); validateDbMajorVersion(majorVersion); + // Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` + // key right after `db.major_version`, unconditionally — see + // {@link validateStorageBucketNames}. + validateStorageBucketNames(config.storage.buckets); // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same // enabled-gated pattern as `api.port` above. @@ -690,6 +825,18 @@ export function legacyResolveLocalConfigValues( authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; + // Go's `Config.Validate` runs `Auth.Hook.validate()` then `Auth.MFA.validate()` + // right after signing keys/passkey validation, still inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1136-1139`). Passkey/webauthn (`config.go:1117-1135`) sits + // between signing keys and hooks in Go but isn't ported by this resolver yet. + if (authEnabled) { + validateAuthHooks(config.auth.hook); + validateMfaConfig(config.auth.mfa); + } + // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` + // key right after the auth block/`generateAPIKeys`, unconditionally — see + // {@link validateFunctionSlugs}. + validateFunctionSlugs(config.functions); // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth // block and the functions loop (`pkg/config/config.go:1158-1173`), and — // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index a7b0d19efe..836bbfc4f9 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -496,6 +496,28 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] + // key right after db.major_version, unconditionally — there is no + // storage.enabled-style gate (pkg/config/config.go:1063-1068). + describe("storage.buckets (bucket-name validation)", () => { + it("rejects a bucket name Go's ValidateBucketName refuses", () => { + const config = baseConfig({ storage: { buckets: { "bad/name": {} } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid Bucket name: bad/name.", + ); + }); + + it("does not throw for a valid bucket name", () => { + const config = baseConfig({ storage: { buckets: { "avatars.public": {} } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw when no buckets are configured", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + // Go's Config.Validate rejects an invalid edge_runtime.deno_version // unconditionally — NOT gated on edge_runtime.enabled // (pkg/config/config.go:1164-1173). @@ -956,6 +978,204 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.hook.* (URI/secret validation when enabled)", () => { + // Go's `Config.Validate` runs `Auth.Hook.validate()` right after signing + // keys/passkey validation, still inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1136-1139`). + it("rejects an enabled hook without a uri", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { custom_access_token: { enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.hook.custom_access_token.uri", + ); + }); + + it("rejects an http(s) hook uri without secrets", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { enabled: true, uri: "https://example.test/hook" }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.hook.custom_access_token.secrets", + ); + }); + + it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'auth.hook.custom_access_token.secrets must be formatted as "v1,whsec_"', + ); + }); + + it("does not throw for a valid http(s) hook secret", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.test/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a pg-functions hook uri with secrets set", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "pg-functions://postgres/public/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "auth.hook.custom_access_token.secrets is unsupported for pg-functions URI", + ); + }); + + it("does not throw for a pg-functions hook uri without secrets", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { enabled: true, uri: "pg-functions://postgres/public/hook" }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a hook uri with an unsupported scheme", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { custom_access_token: { enabled: true, uri: "ftp://example.test/hook" } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI", + ); + }); + + it("does not throw for a disabled hook, however incomplete", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { custom_access_token: { enabled: false } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw an enabled hook without a uri when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, hook: { custom_access_token: { enabled: true } } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.mfa.* (enroll_enabled requires verify_enabled)", () => { + // Go's `(m *mfa) validate()` (`pkg/config/config.go:1523-1534`), called right + // after `Auth.Hook.validate()`, still inside `if c.Auth.Enabled`. + it.each([ + ["totp", "auth.mfa.totp.enroll_enabled requires verify_enabled"], + ["phone", "auth.mfa.phone.enroll_enabled requires verify_enabled"], + ["web_authn", "auth.mfa.web_authn.enroll_enabled requires verify_enabled"], + ] as const)("rejects %s enroll_enabled without verify_enabled", (factor, message) => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + mfa: { [factor]: { enroll_enabled: true, verify_enabled: false } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow(message); + }); + + it("does not throw when enroll_enabled and verify_enabled are both true", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + mfa: { totp: { enroll_enabled: true, verify_enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw an enroll_enabled MFA factor without verify_enabled when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, mfa: { totp: { enroll_enabled: true, verify_enabled: false } } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + // Go's Config.Validate runs ValidateFunctionSlug over every [functions.*] key + // right after the auth block/generateAPIKeys, unconditionally — NOT gated on + // auth.enabled (pkg/config/config.go:1155-1163). + describe("functions.* (function-slug validation)", () => { + it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + const config = baseConfig({ functions: { "1bad": {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid Function name: 1bad.", + ); + }); + + it("does not throw for a valid function slug", () => { + const config = baseConfig({ functions: { "hello-world_v2": {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw when no functions are configured", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an invalid function slug even when auth is disabled", () => { + const config = baseConfig({ auth: { enabled: false }, functions: { "1bad": {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid Function name: 1bad.", + ); + }); + }); + describe("api.tls (cert/key validation)", () => { const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); From f21ab3568fa7529ce670cb2557636ede7aec638d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 18:36:23 +0100 Subject: [PATCH 61/81] fix(cli): validate auth.third_party providers in status/stop resolver (review: #5765) Go's Config.Validate calls ThirdParty.validate() unconditionally inside if c.Auth.Enabled (pkg/config/config.go:1151), rejecting an enabled provider missing its required field (e.g. firebase without project_id) or more than one provider enabled at once. legacyResolveLocalConfigValues never read config.auth.third_party at all, so native status/default stop could succeed on a config Go rejects. Hoists the clerk domain pattern already ported for the db/migration path instead of duplicating it. --- .../shared/legacy-db-config.toml-read.ts | 6 +- .../shared/legacy-local-config-values.ts | 85 +++++++++++ .../legacy-local-config-values.unit.test.ts | 136 ++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index a3e34d1b16..d7eaf6f8e7 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -777,8 +777,10 @@ const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; // `legacy-local-config-values.ts`'s `status`/`stop` resolver — hoisted rather than // duplicated per this package's "hoist before you duplicate" rule. export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; -// Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). -const LEGACY_CLERK_DOMAIN_PATTERN = +// Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). Exported: also used by +// `legacy-local-config-values.ts`'s `status`/`stop` resolver — hoisted rather than +// duplicated per this package's "hoist before you duplicate" rule. +export const LEGACY_CLERK_DOMAIN_PATTERN = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; /** diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 3073fafea7..064a024bdc 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -8,6 +8,7 @@ import { Schema } from "effect"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { LEGACY_BUCKET_NAME_PATTERN, + LEGACY_CLERK_DOMAIN_PATTERN, LEGACY_FUNCTION_SLUG_PATTERN, LEGACY_HOOK_SECRET_PATTERN, legacyParseGoBool, @@ -502,6 +503,86 @@ function validateMfaConfig(mfa: ProjectConfig["auth"]["mfa"]): void { } } +/** + * Go's `(tpa *thirdParty) validate()` (`pkg/config/config.go:1635-1683`), called + * from `Config.Validate` right after `Auth.Email.validate()` (`config.go:1151- + * 1153`, itself right after `Auth.MFA.validate()`) — all inside `if + * c.Auth.Enabled`. Sms/External sit between Email and ThirdParty in Go + * (`config.go:1145-1150`) but aren't ported by this resolver yet, matching the + * existing pattern of documented-but-unported gaps in this file. Each provider + * enabled without its required field fails immediately (Go checks each + * provider's fields before moving to the next); only once every enabled + * provider individually validates does the "more than one enabled" check run. + * `assertEnvLoaded` WARN-only calls (`config.go:1567-1602`) aren't ported, same + * as this file's existing `auth.captcha.secret` precedent. + */ +function validateThirdPartyAuth(thirdParty: ProjectConfig["auth"]["third_party"]): void { + let enabledCount = 0; + + if (thirdParty.firebase.enabled) { + enabledCount += 1; + if ( + thirdParty.firebase.project_id === undefined || + thirdParty.firebase.project_id.length === 0 + ) { + throw new Error( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + } + } + if (thirdParty.auth0.enabled) { + enabledCount += 1; + if (thirdParty.auth0.tenant === undefined || thirdParty.auth0.tenant.length === 0) { + throw new Error("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); + } + } + if (thirdParty.aws_cognito.enabled) { + enabledCount += 1; + if ( + thirdParty.aws_cognito.user_pool_id === undefined || + thirdParty.aws_cognito.user_pool_id.length === 0 + ) { + throw new Error( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + } + if ( + thirdParty.aws_cognito.user_pool_region === undefined || + thirdParty.aws_cognito.user_pool_region.length === 0 + ) { + throw new Error( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + } + } + if (thirdParty.clerk.enabled) { + enabledCount += 1; + const domain = thirdParty.clerk.domain; + if (domain === undefined || domain.length === 0) { + throw new Error("Invalid config: auth.third_party.clerk is enabled but without a domain."); + } + if (!LEGACY_CLERK_DOMAIN_PATTERN.test(domain)) { + throw new Error( + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + ); + } + } + if (thirdParty.workos.enabled) { + enabledCount += 1; + if (thirdParty.workos.issuer_url === undefined || thirdParty.workos.issuer_url.length === 0) { + throw new Error( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + } + } + + if (enabledCount > 1) { + throw new Error( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + } +} + /** * Go's `Config.Validate`'s `switch c.Db.MajorVersion` (`pkg/config/config.go: * 1034-1061`): `0` is the zero-value/missing case, `12` has a dedicated @@ -617,6 +698,9 @@ function envOverrideDenoVersion( * or `secrets` fails Go's scheme/secret-pattern rules — see {@link validateAuthHooks}. * @throws when `auth.enabled` is true and an MFA factor's `enroll_enabled` is set * without `verify_enabled` — see {@link validateMfaConfig}. + * @throws when `auth.enabled` is true and an enabled `[auth.third_party.*]` + * provider is missing its required field, or more than one provider is enabled + * at once — see {@link validateThirdPartyAuth}. * @throws when a `[functions.*]` key doesn't match Go's function-slug pattern — * see {@link validateFunctionSlugs}. Unconditional, not gated on `auth.enabled`. * @throws when `edge_runtime.deno_version` (post-override) is `0` or otherwise @@ -832,6 +916,7 @@ export function legacyResolveLocalConfigValues( if (authEnabled) { validateAuthHooks(config.auth.hook); validateMfaConfig(config.auth.mfa); + validateThirdPartyAuth(config.auth.third_party); } // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` // key right after the auth block/`generateAPIKeys`, unconditionally — see diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 836bbfc4f9..af9ac04c43 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -1147,6 +1147,142 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.third_party.* (thirdParty.validate())", () => { + // Go's `(tpa *thirdParty) validate()` (`pkg/config/config.go:1635-1683`), called + // right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. + it("rejects firebase enabled without a project_id", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { firebase: { enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + }); + + it("rejects auth0 enabled without a tenant", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { auth0: { enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", + ); + }); + + it("rejects aws_cognito enabled without a user_pool_id", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { aws_cognito: { enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + }); + + it("rejects aws_cognito enabled with a user_pool_id but no user_pool_region", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { aws_cognito: { enabled: true, user_pool_id: "pool-1" } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + }); + + it("rejects clerk enabled without a domain", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { clerk: { enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.clerk is enabled but without a domain.", + ); + }); + + it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { clerk: { enabled: true, domain: "not-a-clerk-domain" } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.clerk has invalid domain", + ); + }); + + it("does not throw for a valid clerk.example.com domain", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects workos enabled without an issuer_url", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { workos: { enabled: true } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + }); + + it("rejects more than one third_party provider enabled at once", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { + firebase: { enabled: true, project_id: "proj" }, + auth0: { enabled: true, tenant: "tenant" }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + }); + + it("does not throw when no third_party provider is enabled", () => { + const config = baseConfig({ + auth: { enabled: true, site_url: "http://localhost:3000" }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw an enabled third_party provider missing its required field when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, third_party: { firebase: { enabled: true } } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + // Go's Config.Validate runs ValidateFunctionSlug over every [functions.*] key // right after the auth block/generateAPIKeys, unconditionally — NOT gated on // auth.enabled (pkg/config/config.go:1155-1163). From 8e7610bcc158d23106b2eac700a0818787ca8ee9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 18:41:31 +0100 Subject: [PATCH 62/81] fix(cli): validate experimental config in status/stop resolver (review: #5765) Go's Config.Validate calls Experimental.validate() right after the analytics block, rejecting a present [experimental.webhooks] section whose enabled isn't explicitly true (pkg/config/config.go:1846-1854), and invalid JSON in experimental.pgdelta.format_options. The webhooks check hinges on config.toml SECTION PRESENCE, not the decoded value: @supabase/config's schema decode-fills experimental.webhooks = {enabled: false} even when the TOML section is absent, so checking the decoded ProjectConfig directly would reject every default project. Threads LoadedProjectConfig.document (the raw, pre-default TOML) through legacyResolveLocalConfigValues as a new optional parameter so this (and a future passkey/webauthn check that needs the same raw access) can inspect presence directly. --- .../shared/legacy-local-config-values.ts | 74 +++++++++++++++++ .../legacy-local-config-values.unit.test.ts | 79 +++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 064a024bdc..60aa68da60 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -670,6 +670,62 @@ function envOverrideDenoVersion( return Number(value); } +/** Narrows an unknown value to a plain object, mirroring `legacy-db-config.toml-read.ts`'s `asRecord`. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Go's `(e *experimental) validate()` (`pkg/config/config.go:1846-1854`), + * called from `Config.Validate` right after the analytics/bigquery block and + * right before `Validate` returns (`config.go:1188-1190`) — unconditionally, + * with no `enabled`-style gate of its own. + * + * The webhooks check is NOT "the user disabled a feature" — Go's bool + * zero-value is `false`, so `e.Webhooks != nil && !e.Webhooks.Enabled` rejects + * ANY present `[experimental.webhooks]` section whose `enabled` isn't + * explicitly `true`, including one where the key is simply omitted; the + * section exists only so it can be turned on, never explicitly off. This + * hinges on PRESENCE of the TOML section, not the decoded `enabled` value — + * unlike the doc comment one might expect from `Schema.optionalKey`, + * `@supabase/config`'s decode-time default (`packages/config/src/ + * experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills in + * `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` + * even when the TOML section is entirely absent — verified empirically, this + * default-fill erases exactly the presence signal this check needs. So this + * reads `LoadedProjectConfig.document` (the raw, pre-default TOML) instead, + * the same document-based approach {@link validatePasskeyWebauthn} uses for a + * field the schema doesn't model at all. + */ +function validateExperimentalConfig( + experimental: ProjectConfig["experimental"], + experimentalDocument: Record | undefined, +): void { + if ( + asRecord(experimentalDocument?.["webhooks"]) !== undefined && + experimental.webhooks?.enabled !== true + ) { + throw new Error( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + } + const formatOptions = experimental.pgdelta?.format_options; + if (formatOptions !== undefined && formatOptions.length > 0 && !isValidJson(formatOptions)) { + throw new Error("Invalid config for experimental.pgdelta.format_options: must be valid JSON"); + } +} + +function isValidJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + /** * @throws when `project_id` (post-override) is an explicit empty string. Go's * `Config.Validate` checks this FIRST, before every other field @@ -716,12 +772,25 @@ function envOverrideDenoVersion( * @throws when `analytics.enabled` (post-override) is true, `analytics.backend` * (post-override) is `"bigquery"`, and `analytics.gcp_project_id`, * `analytics.gcp_project_number`, or `analytics.gcp_jwt_path` is unset. + * @throws when a `[experimental.webhooks]` section is present without an + * explicit `enabled = true`, or `experimental.pgdelta.format_options` is set + * but isn't valid JSON — see {@link validateExperimentalConfig}. */ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, workdir: string, projectEnvValues: Readonly> | undefined = undefined, + /** + * `LoadedProjectConfig.document` (`packages/config/src/io.ts`) — the raw, + * pre-schema-default TOML document `config` was decoded from. Lets checks + * that hinge on TOML-section PRESENCE (not the decoded, always-defaulted + * value) inspect the file directly — see {@link validateExperimentalConfig} + * and {@link validatePasskeyWebauthn}. `undefined` for callers that haven't + * threaded it through yet (e.g. most existing unit tests); those checks are + * then simply skipped rather than guessed at. + */ + document: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above @@ -977,6 +1046,11 @@ export function legacyResolveLocalConfigValues( } } + // Go's `Config.Validate` calls `c.Experimental.validate()` right after the + // analytics/bigquery block and right before returning — see + // {@link validateExperimentalConfig}. + validateExperimentalConfig(config.experimental, asRecord(document?.["experimental"])); + return { apiUrl: apiExternalUrl, restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index af9ac04c43..0d15afdcbe 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -674,6 +674,85 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("experimental.* (experimental.validate())", () => { + // Go's `(e *experimental) validate()` (`pkg/config/config.go:1846-1854`), + // called right after the analytics/bigquery block and right before + // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. + // + // The webhooks check hinges on whether `[experimental.webhooks]` is + // PRESENT in config.toml, not the decoded `enabled` value — the shared + // schema decode-fills `experimental.webhooks = { enabled: false }` even + // when the section is entirely absent, so these tests pass the raw + // `document` (the 5th param) to simulate what config.toml actually + // contained, exactly like `LoadedProjectConfig.document` would. + it("rejects a present [experimental.webhooks] section with enabled omitted", () => { + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + experimental: { webhooks: {} }, + }), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("rejects a present [experimental.webhooks] section with enabled = false", () => { + const config = baseConfig({ experimental: { webhooks: { enabled: false } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + experimental: { webhooks: { enabled: false } }, + }), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("does not throw when [experimental.webhooks] enabled = true", () => { + const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + experimental: { webhooks: { enabled: true } }, + }), + ).not.toThrow(); + }); + + it("does not throw when [experimental.webhooks] is absent entirely", () => { + const config = baseConfig(); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, {}), + ).not.toThrow(); + }); + + it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { + // No `document` (5th param) at all — e.g. a caller that hasn't threaded + // `LoadedProjectConfig.document` through yet. The presence-only check + // can't run without it, so it's skipped rather than guessed at; this + // also covers every pre-existing call site/test in this file that + // doesn't pass a 5th argument. + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects invalid JSON in experimental.pgdelta.format_options", () => { + const config = baseConfig({ experimental: { pgdelta: { format_options: "{not json" } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + ); + }); + + it("does not throw for valid JSON in experimental.pgdelta.format_options", () => { + const config = baseConfig({ + experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw when experimental.pgdelta.format_options is unset", () => { + const config = baseConfig({ experimental: { pgdelta: {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + describe("SUPABASE_API_TLS_ENABLED env override", () => { // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), From 1a48c6db0a3677530d441d6056a2b9349944c3b0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 18:44:00 +0100 Subject: [PATCH 63/81] fix(cli): validate auth email template content_path in status/stop resolver (review: #5765) Go's Config.Validate calls Auth.Email.validate(fsys) right after Auth.MFA.validate(), reading every template's content_path unconditionally and every enabled notification's content_path, hard-failing on a missing/unreadable file (pkg/config/config.go:1293- 1313). legacyResolveLocalConfigValues never read config.auth.email at all, so native status/default stop could succeed on a config Go rejects. Matches Go's asymmetric relative-path bases (config.go:900-916, its own FIXME comment): template paths resolve against the workdir itself, notification paths against /supabase. --- .../shared/legacy-local-config-values.ts | 65 ++++++++++- .../legacy-local-config-values.unit.test.ts | 107 ++++++++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 60aa68da60..520c57ed32 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -583,6 +583,57 @@ function validateThirdPartyAuth(thirdParty: ProjectConfig["auth"]["third_party"] } } +/** + * Go's `(e *email) validate(fsys)` template/notification file-existence + * checks (`pkg/config/config.go:1293-1313`), called from `Config.Validate` + * right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` + * (`config.go:1142`). Every template's `content_path` is checked + * unconditionally; a notification's only when that notification is itself + * enabled (`config.go:1308`). Uses the same `readFileSync`-based pattern as + * {@link loadFirstSigningKey}/{@link validateLocalApiTls} in this file, not an + * Effect `FileSystem` service. + * + * Relative paths resolve against different bases, per Go's `(c *baseConfig) + * resolve` (`config.go:900-916`, flagged there with a `// FIXME: only email + * template is relative to repo directory`): TEMPLATE paths resolve against + * the project root (`workdir`), NOTIFICATION paths resolve against + * `/supabase` — this asymmetry is intentional Go behavior to match, + * not a bug to fix. + * + * Go's `content`-vs-`content_path` mutual-exclusivity check (`config.go:1296- + * 1298,1313-1315`) isn't reproduced: `@supabase/config`'s `template`/ + * `notification` schema (`packages/config/src/auth/email.ts`) has no + * `content` field at all, so that branch can never trigger through the + * decoded config — the same documented-gap style already used for the + * analytics-backend-enum note above. + */ +function validateAuthEmailTemplates(email: ProjectConfig["auth"]["email"], workdir: string): void { + for (const [name, tmpl] of Object.entries(email.template)) { + if (tmpl.content_path.length === 0) continue; + readEmailContentPath("template", name, tmpl.content_path, workdir); + } + for (const [name, tmpl] of Object.entries(email.notification)) { + if (!tmpl.enabled || tmpl.content_path.length === 0) continue; + readEmailContentPath("notification", name, tmpl.content_path, join(workdir, "supabase")); + } +} + +function readEmailContentPath( + section: "template" | "notification", + name: string, + contentPath: string, + base: string, +): void { + const absolutePath = isAbsolute(contentPath) ? contentPath : join(base, contentPath); + try { + readFileSync(absolutePath, "utf8"); + } catch (cause) { + throw new Error( + `Invalid config for auth.email.${section}.${name}.content_path: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } +} + /** * Go's `Config.Validate`'s `switch c.Db.MajorVersion` (`pkg/config/config.go: * 1034-1061`): `0` is the zero-value/missing case, `12` has a dedicated @@ -754,6 +805,9 @@ function isValidJson(value: string): boolean { * or `secrets` fails Go's scheme/secret-pattern rules — see {@link validateAuthHooks}. * @throws when `auth.enabled` is true and an MFA factor's `enroll_enabled` is set * without `verify_enabled` — see {@link validateMfaConfig}. + * @throws when `auth.enabled` is true and an email template's `content_path` + * (or an enabled notification's) can't be read — see + * {@link validateAuthEmailTemplates}. * @throws when `auth.enabled` is true and an enabled `[auth.third_party.*]` * provider is missing its required field, or more than one provider is enabled * at once — see {@link validateThirdPartyAuth}. @@ -978,13 +1032,16 @@ export function legacyResolveLocalConfigValues( authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; - // Go's `Config.Validate` runs `Auth.Hook.validate()` then `Auth.MFA.validate()` - // right after signing keys/passkey validation, still inside `if c.Auth.Enabled` - // (`pkg/config/config.go:1136-1139`). Passkey/webauthn (`config.go:1117-1135`) sits - // between signing keys and hooks in Go but isn't ported by this resolver yet. + // Go's `Config.Validate` runs `Auth.Hook.validate()`, then `Auth.MFA.validate()`, + // then `Auth.Email.validate()`, then (skipping the unported Sms/External steps) + // `Auth.ThirdParty.validate()`, all right after signing keys/passkey validation and + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1136-1153`). Passkey/webauthn + // (`config.go:1117-1135`) sits between signing keys and hooks in Go but isn't ported + // by this resolver yet; Sms/External (`config.go:1145-1150`) aren't ported either. if (authEnabled) { validateAuthHooks(config.auth.hook); validateMfaConfig(config.auth.mfa); + validateAuthEmailTemplates(config.auth.email, workdir); validateThirdPartyAuth(config.auth.third_party); } // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 0d15afdcbe..8a30628da1 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -1226,6 +1226,113 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.email.template/notification (content_path validation)", () => { + // Go's `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`), + // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. + const tempRoot = useLegacyTempWorkdir("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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }); + + 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(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a template with no content_path configured", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(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" } }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }); + + it("resolves a relative notification content_path against /supabase", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a disabled notification's missing content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(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(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + }); + describe("auth.third_party.* (thirdParty.validate())", () => { // Go's `(tpa *thirdParty) validate()` (`pkg/config/config.go:1635-1683`), called // right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. From 4c7dd3b5618f513c040f9da6de51a0f9cbaaaac1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 18:48:31 +0100 Subject: [PATCH 64/81] fix(cli): validate studio.api_url via a Go-parity net/url.Parse port (review: #5765) Go's Config.Validate parses studio.api_url with net/url.Parse right after the studio.port check, still inside if c.Studio.Enabled (pkg/config/config.go:1074-1078), rejecting malformed hosts such as an unterminated IPv6 literal (http://[::1). legacyResolveLocalConfigValues never checked studio.api_url at all, so native status/default stop could accept a config Go rejects. Extends legacy-storage-url.ts's legacyGoUrlParse (previously Scheme/Path only, since storage ss:// URLs never have a host) with a faithful port of net/url.parseHost's bracket/port validation, verified byte-for-byte against the real Go stdlib. --- .../shared/legacy-local-config-values.ts | 45 +++++++++++++- .../legacy-local-config-values.unit.test.ts | 29 +++++++++ .../src/legacy/shared/legacy-storage-url.ts | 61 +++++++++++++++++++ .../shared/legacy-storage-url.unit.test.ts | 47 ++++++++++++++ 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 520c57ed32..470fbdf093 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -18,6 +18,7 @@ import { legacyGenerateGoJwt, type LegacyJwk, } from "./legacy-go-jwt.ts"; +import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** * Go-parity derived local-dev config values, ported from `utils.Config`'s @@ -405,6 +406,30 @@ function validateStorageBucketNames(buckets: ProjectConfig["storage"]["buckets"] } } +/** + * Go's `Config.Validate` studio.api_url check (`pkg/config/config.go:1074- + * 1078`), gated on the same `studio.enabled` check as the port validation + * right above it in Go's source. Reuses {@link legacyGoUrlParse}'s + * `net/url.Parse` port (already used by the storage commands) rather than a + * TS-only URL/regex check, since Go's own parser is what decides pass/fail + * here — e.g. `http://[::1` (an unterminated IPv6 literal) is a genuine + * `net/url.Parse` failure, not something Go's usual scheme/path leniency + * would let through. Go's success branch also mutates `Studio.ApiUrl` in some + * cases (`config.go:1076-1077`), but neither `status` nor `stop` ever read it + * afterwards (Go's own `status.go` derives `StudioURL` from `Hostname`+`Port` + * directly, matching this resolver's own `studioUrl` field below), so only + * the validation gate — not the mutation — is reproduced. + */ +function validateStudioApiUrl(apiUrl: string): void { + try { + legacyGoUrlParse(apiUrl); + } catch (cause) { + throw new Error( + `Invalid config for studio.api_url: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } +} + /** * Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` key * right after the auth block/`generateAPIKeys`, before `edge_runtime.deno_version` @@ -816,7 +841,9 @@ function isValidJson(value: string): boolean { * @throws when `edge_runtime.deno_version` (post-override) is `0` or otherwise * not `1`/`2` — see {@link validateDenoVersion}. Unconditional, not gated on * `edge_runtime.enabled`. - * @throws when `studio.enabled` is true and `studio.port` (post-override) is `0`. + * @throws when `studio.enabled` is true and `studio.port` (post-override) is `0`, + * or `studio.api_url` (post-override) fails Go's `net/url.Parse` — see + * {@link validateStudioApiUrl}. * @throws when `local_smtp.enabled` is true and `local_smtp.port` (post-override) is `0`. * @throws when `auth.enabled` is true and `auth.site_url` is empty. * @throws when `auth.enabled` is true, `auth.captcha.enabled` (post-override) is true, @@ -951,8 +978,20 @@ export function legacyResolveLocalConfigValues( "studio.port", projectEnvValues, ); - if (studioEnabled && studioPort === 0) { - throw new Error("Missing required field in config: studio.port"); + if (studioEnabled) { + if (studioPort === 0) { + throw new Error("Missing required field in config: studio.port"); + } + // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` + // right after the port check, still inside `if c.Studio.Enabled` + // (`pkg/config/config.go:1074-1078`) — see {@link validateStudioApiUrl}. + // `config.studio.api_url` is a required (defaulted) field, so `envOverride` + // can only return `undefined` here if that default itself were somehow + // undefined — the `??` fallback just satisfies that generic signature. + validateStudioApiUrl( + envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url, + ); } // Go's `Config.Validate` rejects `local_smtp.port === 0`/ // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 8a30628da1..6e12c2422d 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -276,6 +276,7 @@ describe("legacyResolveLocalConfigValues", () => { "SUPABASE_LOCAL_SMTP_PORT", "SUPABASE_API_PORT", "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_STUDIO_API_URL", ] as const; afterEach(() => { @@ -410,6 +411,34 @@ describe("legacyResolveLocalConfigValues", () => { expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); + // Go's Config.Validate parses studio.api_url with net/url.Parse right + // after the port check, still inside `if c.Studio.Enabled` + // (pkg/config/config.go:1074-1078). + it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { + const config = baseConfig({ studio: { api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(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(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw for the default studio.api_url", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(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"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's // struct field is still named `Inbucket` for the `[local_smtp]` TOML // section, pkg/config/config.go:235,1081-1083), same pattern as api.port/ diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 4401c56387..0810c61872 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -155,6 +155,62 @@ function hostFromAuthority(authority: string): string { return at === -1 ? authority : authority.slice(at + 1); } +/** Go `net/url.validOptionalPort` (`url.go:761-774`): `""`, or `:` followed by only digits. */ +function isValidOptionalPort(port: string): boolean { + if (port.length === 0) return true; + if (port.charCodeAt(0) !== 0x3a /* : */) return false; + for (let i = 1; i < port.length; i++) { + if (!isDigit(port.charCodeAt(i))) return false; + } + return true; +} + +/** + * Go `net/url.parseHost`, restricted to the branches this module's callers + * can actually hit (`net/url/url.go:544-608` in the Go stdlib `apps/cli-go` + * builds against): IP-literal (`[...]`) bracket/port validation, and the bare + * `host[:port]` port-digit check. Deliberately does NOT implement IPv6 + * zone-identifier percent-decoding or `netip.ParseAddr` address validation + * (Go additionally requires a bracketed literal to parse as a valid, + * non-IPv4 IP address) — callers only need to know whether validation + * THROWS, and a config author writing a bogus-but-bracket-balanced IPv6 + * literal is exotic enough that the narrower `netip.ParseAddr` check isn't + * worth the added surface; the common bracket-mismatch/invalid-port mistakes + * Go actually raises for realistic input (e.g. `http://[::1`, a truncated + * IPv6 literal) are still reproduced byte-for-byte. Also skips Go's + * `GODEBUG=urlstrictcolons=0` legacy multi-colon opt-out (`url.go:596-604`) — + * an explicit, non-default env var this codebase's bundled Go binary doesn't + * set, so the http/https "first colon is the port separator" behavior always + * applies for those schemes; other schemes use the last colon, matching Go's + * unconditional `else` branch for non-http(s) schemes. + */ +function validateGoUrlHost(scheme: string, host: string): void { + if (host.length === 0) return; + const openBracketIdx = host.indexOf("["); + if (openBracketIdx > 0) { + throw new Error("invalid IP-literal"); + } + if (openBracketIdx === 0) { + const closeBracketIdx = host.lastIndexOf("]"); + if (closeBracketIdx < 0) { + throw new Error("missing ']' in host"); + } + const colonPort = host.slice(closeBracketIdx + 1); + if (!isValidOptionalPort(colonPort)) { + throw new Error(`invalid port ${JSON.stringify(colonPort)} after host`); + } + return; + } + const colonIdx = + scheme === "http" || scheme === "https" ? host.indexOf(":") : host.lastIndexOf(":"); + if (colonIdx !== -1) { + const colonPort = host.slice(colonIdx); + if (!isValidOptionalPort(colonPort)) { + throw new Error(`invalid port ${JSON.stringify(colonPort)} after host`); + } + } +} + /** * Port of Go's `url.Parse` restricted to `Scheme`/`Host`/`Path`. Throws * `LegacyGoUrlParseError` (Go's `*url.Error`) on the failures the storage @@ -208,6 +264,11 @@ export function legacyGoUrlParse(rawURL: string): LegacyGoUrl { const authority = slash === -1 ? afterSlashes : afterSlashes.slice(0, slash); rest = slash === -1 ? "" : afterSlashes.slice(slash); host = hostFromAuthority(authority); + try { + validateGoUrlHost(scheme, host); + } catch (cause) { + throw new LegacyGoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + } } let path: string; diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts b/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts index 5e9643f48e..6100802123 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts @@ -4,6 +4,7 @@ import { LegacyGoUrlParseError, LegacyStorageUrlPatternError, legacyDetectScheme, + legacyGoUrlParse, legacyParseStorageUrl, legacySplitBucketPrefix, legacyStorageIsDir, @@ -85,6 +86,52 @@ describe("legacyDetectScheme", () => { }); }); +// Oracle: `go run` against Go 1.25's `net/url.Parse` directly (net/url/url.go's +// `parseHost`) — used by `studio.api_url` validation, which needs the Host, not +// just Scheme/Path, so the failures below matter beyond the storage commands. +describe("legacyGoUrlParse (host validation)", () => { + it("rejects an unterminated IPv6 literal", () => { + expect(() => legacyGoUrlParse("http://[::1")).toThrow(LegacyGoUrlParseError); + expect(() => legacyGoUrlParse("http://[::1")).toThrow( + `parse "http://[::1": missing ']' in host`, + ); + }); + + it("accepts a bracketed IPv6 literal with no port", () => { + expect(legacyGoUrlParse("http://[::1]").host).toBe("[::1]"); + }); + + it("accepts a bracketed IPv6 literal with a valid port", () => { + expect(legacyGoUrlParse("http://[::1]:8080").host).toBe("[::1]:8080"); + }); + + it("rejects a bracketed IPv6 literal with a non-numeric port", () => { + expect(() => legacyGoUrlParse("http://[::1]:abc")).toThrow( + `parse "http://[::1]:abc": invalid port ":abc" after host`, + ); + }); + + it("rejects a bracket that isn't the first character of the host", () => { + expect(() => legacyGoUrlParse("http://host[::1]")).toThrow( + `parse "http://host[::1]": invalid IP-literal`, + ); + }); + + it("accepts a plain hostname with a numeric port", () => { + expect(legacyGoUrlParse("http://example.com:99999").host).toBe("example.com:99999"); + }); + + it("rejects more than one colon in an http(s) host (strict-colon default)", () => { + expect(() => legacyGoUrlParse("http://host:1:2")).toThrow( + `parse "http://host:1:2": invalid port ":1:2" after host`, + ); + }); + + it("does not validate a host for a scheme with no authority (ss:///bucket)", () => { + expect(() => legacyGoUrlParse("ss:///bucket/x")).not.toThrow(); + }); +}); + describe("legacyStorageIsDir", () => { it.each([ ["", true], From 6b8d4e9f73731e1a0b270d0de4ffb0778ea0216e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 18:52:54 +0100 Subject: [PATCH 65/81] fix(cli): enforce passkey/WebAuthn requirement in status/stop resolver (review: #5765) Go's Config.Validate rejects [auth.passkey] enabled = true configured without a complete [auth.webauthn] (rp_id/rp_origins), right after the signing-keys read and before Auth.Hook.validate() (pkg/config/config.go:1117-1129). legacyResolveLocalConfigValues never read this at all, so native status/default stop could continue for a config Go refuses. @supabase/config's auth schema has no passkey/webauthn fields, so this threads LoadedProjectConfig.document (the raw, pre-schema-default TOML) through legacyResolveLocalConfigValues -> legacyResolveStatusLocalState -> status.handler.ts/stop.handler.ts, the same document-based approach already used for the equivalent check on the db/migration config-load path (legacy-db-config.toml-read.ts's legacyValidateAuthConfig, A6). --- .../legacy/commands/status/status.handler.ts | 8 ++- .../legacy/commands/status/status.values.ts | 20 +++++- .../src/legacy/commands/stop/stop.handler.ts | 1 + .../shared/legacy-local-config-values.ts | 58 +++++++++++++-- .../legacy-local-config-values.unit.test.ts | 71 +++++++++++++++++++ 5 files changed, 149 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 88cbf4b949..77b1f461fe 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -202,7 +202,13 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const hostname = legacyGetHostname(); const localState = yield* Effect.try({ try: () => - legacyResolveStatusLocalState(config, hostname, cliConfig.workdir, projectEnvValues), + legacyResolveStatusLocalState( + config, + hostname, + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index aeb41cf108..4759a75af6 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -287,8 +287,16 @@ export function legacyResolveStatusLocalState( hostname: string, workdir: string, projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, ): LegacyStatusLocalState { - const local = legacyResolveLocalConfigValues(config, hostname, workdir, projectEnvValues); + const local = legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + document, + ); const apiEnabled = legacyEnvOverrideBool( "SUPABASE_API_ENABLED", @@ -472,8 +480,16 @@ export function legacyStatusValues( overrides: ReadonlyMap, workdir: string, projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, ): LegacyStatusValuesResult { - const localState = legacyResolveStatusLocalState(config, hostname, workdir, projectEnvValues); + const localState = legacyResolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); const state = legacyGateStatusState(localState, containerIds, excluded); return legacyStatusValuesFromState(state, overrides); } diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index f4df80d3aa..e90fe5fd20 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -164,6 +164,7 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject legacyGetHostname(), cliConfig.workdir, projectEnvValues, + loaded?.document, ), catch: (cause) => new LegacyStopConfigLoadError({ diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 470fbdf093..c4571bb59e 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -450,6 +450,48 @@ function validateFunctionSlugs(functions: ProjectConfig["functions"]): void { } } +/** + * Go's passkey/WebAuthn requirement inside `Config.Validate` + * (`pkg/config/config.go:1117-1129`; the trailing `assertEnvLoaded` calls at + * `1130-1135` are stderr-only warnings that never fail validation, matching + * this file's existing `auth.captcha.secret` precedent, so they have no + * throwing equivalent here). Runs right after the signing-keys read and + * before `Auth.Hook.validate()`, still inside `if c.Auth.Enabled`. + * + * `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all + * (see `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), + * so this reads the RAW, post-`env()`-interpolation TOML document + * (`LoadedProjectConfig.document`) instead of the decoded `ProjectConfig` — + * the same document-based approach already used for the equivalent check on + * the `db`/migration config-load path (`legacy-db-config.toml-read.ts`'s + * `legacyValidateAuthConfig`, section A6). `authDocument` is `undefined` when + * a caller hasn't threaded `document` through yet, in which case this check + * is simply skipped rather than guessed at. Deliberately does not resolve a + * literal `enabled = "env(VAR)"` string the way + * `legacy-db-config.toml-read.ts`'s `gate()` helper does — Go's `Config.Load` + * resolves that generically via Viper for every field, but wiring the same + * `EnvLookup`/`legacyExpandEnv` machinery into this resolver just for this one + * rarely-configured field isn't worth the added surface; a literal TOML + * `enabled = true`/`false` (the overwhelmingly common case) is handled. + */ +function validatePasskeyWebauthn(authDocument: Record | undefined): void { + const passkey = asRecord(authDocument?.["passkey"]); + if (passkey?.["enabled"] !== true) return; + const webauthn = asRecord(authDocument?.["webauthn"]); + if (webauthn === undefined) { + throw new Error( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + } + if (typeof webauthn["rp_id"] !== "string" || webauthn["rp_id"].length === 0) { + throw new Error("Missing required field in config: auth.webauthn.rp_id"); + } + const rpOrigins = webauthn["rp_origins"]; + if (!Array.isArray(rpOrigins) || rpOrigins.length === 0) { + throw new Error("Missing required field in config: auth.webauthn.rp_origins"); + } +} + /** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`). */ const LEGACY_HOOK_TYPES = [ "mfa_verification_attempt", @@ -826,6 +868,9 @@ function isValidJson(value: string): boolean { * unsupported — see {@link validateDbMajorVersion}. * @throws when a `[storage.buckets.*]` key doesn't match Go's bucket-name pattern — * see {@link validateStorageBucketNames}. Unconditional, no `storage.enabled` gate. + * @throws when `auth.enabled` is true, `[auth.passkey] enabled` is `true` (per the + * raw `document`), and `[auth.webauthn]` is missing or incomplete — see + * {@link validatePasskeyWebauthn}. Skipped when `document` isn't provided. * @throws when `auth.enabled` is true and an enabled `[auth.hook.*]` entry's `uri` * or `secrets` fails Go's scheme/secret-pattern rules — see {@link validateAuthHooks}. * @throws when `auth.enabled` is true and an MFA factor's `enroll_enabled` is set @@ -1071,13 +1116,14 @@ export function legacyResolveLocalConfigValues( authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; - // Go's `Config.Validate` runs `Auth.Hook.validate()`, then `Auth.MFA.validate()`, - // then `Auth.Email.validate()`, then (skipping the unported Sms/External steps) - // `Auth.ThirdParty.validate()`, all right after signing keys/passkey validation and - // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1136-1153`). Passkey/webauthn - // (`config.go:1117-1135`) sits between signing keys and hooks in Go but isn't ported - // by this resolver yet; Sms/External (`config.go:1145-1150`) aren't ported either. + // Go's `Config.Validate` runs passkey/webauthn validation, then + // `Auth.Hook.validate()`, then `Auth.MFA.validate()`, then + // `Auth.Email.validate()`, then (skipping the unported Sms/External steps) + // `Auth.ThirdParty.validate()`, all right after the signing-keys read and + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1117-1153`). + // Sms/External (`config.go:1145-1150`) aren't ported. if (authEnabled) { + validatePasskeyWebauthn(asRecord(document?.["auth"])); validateAuthHooks(config.auth.hook); validateMfaConfig(config.auth.mfa); validateAuthEmailTemplates(config.auth.email, workdir); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 6e12c2422d..06ffb58d0b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -1086,6 +1086,77 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.passkey / auth.webauthn (WebAuthn requirement when passkey enabled)", () => { + // Go's Config.Validate rejects [auth.passkey] enabled = true without a + // complete [auth.webauthn] (pkg/config/config.go:1117-1129), right after + // the signing-keys read and before Auth.Hook.validate(). @supabase/config's + // schema has no passkey/webauthn fields at all, so this check only runs + // when the raw `document` (5th param) is provided. + it("rejects passkey.enabled without an [auth.webauthn] section", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { passkey: { enabled: true } }, + }), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_id", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { passkey: { enabled: true }, webauthn: { rp_origins: ["http://localhost:3000"] } }, + }), + ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_origins", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost" } }, + }), + ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + }); + + it("does not throw when passkey.enabled has a complete [auth.webauthn] section", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { + passkey: { enabled: true }, + webauthn: { rp_id: "localhost", rp_origins: ["http://localhost:3000"] }, + }, + }), + ).not.toThrow(); + }); + + it("does not throw when passkey is absent from the document", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { auth: {} }), + ).not.toThrow(); + }); + + it("does not throw passkey.enabled without webauthn when no document is provided", () => { + // No `document` (5th param) at all — the same skip-rather-than-guess + // behavior every pre-existing call site/test in this file relies on. + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw an enabled passkey without webauthn when auth is disabled", () => { + const config = baseConfig({ auth: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { passkey: { enabled: true } }, + }), + ).not.toThrow(); + }); + }); + describe("auth.hook.* (URI/secret validation when enabled)", () => { // Go's `Config.Validate` runs `Auth.Hook.validate()` right after signing // keys/passkey validation, still inside `if c.Auth.Enabled` From 0461e35a9df910e0a524934a77911e739919c65f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 19:28:13 +0100 Subject: [PATCH 66/81] fix(cli): treat present SMTP table as enabled and validate SUPABASE_ANALYTICS_BACKEND overrides (review: #5765) Two Go-parity gaps in the status/stop config resolver: - Go defaults `auth.email.smtp.enabled = true` when `[auth.email.smtp]` is present but omits `enabled` (pkg/config/config.go:743-748), a presence-based default set on the raw viper map before the struct decodes. The shared schema always decodes a missing `enabled` key to `false`, erasing that presence signal, so this reads the raw TOML document instead (same approach already used for passkey/webauthn and experimental.webhooks). Mirrors the equivalent, already-correct check on the db/migration config-load path (legacy-db-config.toml-read.ts). - `SUPABASE_ANALYTICS_BACKEND` env overrides weren't validated against Go's `LogflareBackend` enum (config.go:60-65), so a bad override (e.g. "mysql") would continue where Go's `UnmarshalText` hard-fails config load. Validates the override-or-configured value with a single check, matching how viper merges both sources into one string before UnmarshalExact runs UnmarshalText exactly once. --- .../shared/legacy-local-config-values.ts | 115 ++++++++++++++++-- .../legacy-local-config-values.unit.test.ts | 96 +++++++++++++++ 2 files changed, 199 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index c4571bb59e..bd568349cc 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -229,6 +229,53 @@ export function legacyEnvOverrideBool( return parsed; } +/** + * Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't match one of Go's `LogflareBackend` values. `Analytics.Backend` is + * typed `LogflareBackend` (`pkg/config/config.go:303`), and + * `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects anything + * outside `{postgres, bigquery}` — that runs inside the same + * `v.UnmarshalExact` decode call (`config.go:749-756`) every other + * `SUPABASE_*` override goes through, so a malformed override fails config + * loading outright, same mechanism as {@link LegacyInvalidPortEnvOverrideError}/ + * {@link LegacyInvalidBoolEnvOverrideError}. + */ +export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super( + `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + ); + this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; + } +} + +/** + * `analytics.backend`-flavored sibling of {@link envOverridePort}/ + * {@link legacyEnvOverrideBool} for the one `SUPABASE_*` override this file + * decodes as a Go text-unmarshalled enum rather than a string/number/bool — + * see {@link LegacyInvalidAnalyticsBackendEnvOverrideError}. Validates the + * override-or-configured value with a SINGLE check (rather than only + * validating the override, trusting the schema for the configured value), + * matching Go more closely: viper merges the config.toml value and any env + * override into one string BEFORE `UnmarshalExact` calls `UnmarshalText` + * exactly once on the resolved value (`config.go:749-756`), not once per + * source. `@supabase/config`'s `stringEnum` (`packages/config/src/ + * analytics.ts:31-39`) already guards the `config.toml`-sourced value at + * decode time, so this is belt-and-suspenders for that source and the sole + * guard for the env-override one, which bypasses that schema entirely. + */ +function envOverrideAnalyticsBackend( + configured: string, + projectEnvValues: Readonly> | undefined, +): "postgres" | "bigquery" { + const value = + envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured; + if (value !== "postgres" && value !== "bigquery") { + throw new LegacyInvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); + } + return value; +} + /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ function resolveJwtSecret(configured: string | undefined): string { if (configured === undefined || configured.length === 0) return defaultJwtSecret; @@ -671,8 +718,8 @@ function validateThirdPartyAuth(thirdParty: ProjectConfig["auth"]["third_party"] * 1298,1313-1315`) isn't reproduced: `@supabase/config`'s `template`/ * `notification` schema (`packages/config/src/auth/email.ts`) has no * `content` field at all, so that branch can never trigger through the - * decoded config — the same documented-gap style already used for the - * analytics-backend-enum note above. + * decoded config — a documented, deliberate gap (unlike the SMTP/analytics + * checks elsewhere in this file, which are actively ported). */ function validateAuthEmailTemplates(email: ProjectConfig["auth"]["email"], workdir: string): void { for (const [name, tmpl] of Object.entries(email.template)) { @@ -701,6 +748,46 @@ function readEmailContentPath( } } +/** + * Go's `[auth.email.smtp]` presence-based `enabled` default + * (`pkg/config/config.go:743-748`): when the TOML table is present but omits + * an `enabled` key, Go sets `auth.email.smtp.enabled = true` on the raw viper + * map BEFORE the struct is decoded — a genuinely presence-based default, not + * a struct-tag one (the Go struct's own zero-value is `false`). That defaulted + * value then gates the required-field check in `(e *email) validate(fsys)` + * (`config.go:1325-1341`), called from `Auth.Email.validate()` + * (`config.go:1142`), still inside `if c.Auth.Enabled`. + * + * `@supabase/config`'s schema (`packages/config/src/auth/email.ts`) defaults + * `smtp.enabled` to `false` at DECODE time regardless of whether the table is + * present, which erases exactly the presence signal this check needs — same + * shape of gap as {@link validatePasskeyWebauthn}/{@link validateExperimentalConfig}, + * so this reads the raw `document` instead of the decoded `ProjectConfig`. + * Mirrors the equivalent, already-correct check on the `db`/migration + * config-load path (`legacy-db-config.toml-read.ts`, section B3). + */ +function validateAuthEmailSmtp(emailDocument: Record | undefined): void { + const smtp = asRecord(emailDocument?.["smtp"]); + if (smtp === undefined) return; + const smtpEnabled = smtp["enabled"] === undefined ? true : smtp["enabled"] === true; + if (!smtpEnabled) return; + if (typeof smtp["host"] !== "string" || smtp["host"].length === 0) { + throw new Error("Missing required field in config: auth.email.smtp.host"); + } + if (typeof smtp["port"] !== "number" || smtp["port"] === 0) { + throw new Error("Missing required field in config: auth.email.smtp.port"); + } + if (typeof smtp["user"] !== "string" || smtp["user"].length === 0) { + throw new Error("Missing required field in config: auth.email.smtp.user"); + } + if (typeof smtp["pass"] !== "string" || smtp["pass"].length === 0) { + throw new Error("Missing required field in config: auth.email.smtp.pass"); + } + if (typeof smtp["admin_email"] !== "string" || smtp["admin_email"].length === 0) { + throw new Error("Missing required field in config: auth.email.smtp.admin_email"); + } +} + /** * Go's `Config.Validate`'s `switch c.Db.MajorVersion` (`pkg/config/config.go: * 1034-1061`): `0` is the zero-value/missing case, `12` has a dedicated @@ -878,6 +965,10 @@ function isValidJson(value: string): boolean { * @throws when `auth.enabled` is true and an email template's `content_path` * (or an enabled notification's) can't be read — see * {@link validateAuthEmailTemplates}. + * @throws when `auth.enabled` is true, `[auth.email.smtp]` is present (per the + * raw `document`) and not explicitly disabled, and `host`/`port`/`user`/`pass`/ + * `admin_email` isn't set — see {@link validateAuthEmailSmtp}. Skipped when + * `document` isn't provided. * @throws when `auth.enabled` is true and an enabled `[auth.third_party.*]` * provider is missing its required field, or more than one provider is enabled * at once — see {@link validateThirdPartyAuth}. @@ -1123,10 +1214,12 @@ export function legacyResolveLocalConfigValues( // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1117-1153`). // Sms/External (`config.go:1145-1150`) aren't ported. if (authEnabled) { - validatePasskeyWebauthn(asRecord(document?.["auth"])); + const authDocument = asRecord(document?.["auth"]); + validatePasskeyWebauthn(authDocument); validateAuthHooks(config.auth.hook); validateMfaConfig(config.auth.mfa); validateAuthEmailTemplates(config.auth.email, workdir); + validateAuthEmailSmtp(asRecord(authDocument?.["email"])); validateThirdPartyAuth(config.auth.third_party); } // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` @@ -1144,21 +1237,19 @@ export function legacyResolveLocalConfigValues( // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`, inside // `func (c *config) Validate` at `config.go:989`): when `analytics.enabled` // and `analytics.backend == "bigquery"`, all three GCP fields are required, - // checked in that order, each with its own message. Backend-enum - // validation (rejecting a non-postgres/bigquery value) is already covered - // at decode time by `@supabase/config`'s `stringEnum` - // (`packages/config/src/analytics.ts:17-41`), so it isn't reproduced here. + // checked in that order, each with its own message. Backend-enum validation + // (rejecting a non-postgres/bigquery value) is covered at decode time for + // the `config.toml`-sourced value by `@supabase/config`'s `stringEnum` + // (`packages/config/src/analytics.ts:17-41`), but that schema doesn't see + // the `SUPABASE_ANALYTICS_BACKEND` env-override path — see + // {@link envOverrideAnalyticsBackend} for that case. const analyticsEnabled = legacyEnvOverrideBool( "SUPABASE_ANALYTICS_ENABLED", config.analytics.enabled, "analytics.enabled", projectEnvValues, ); - const analyticsBackend = envOverride( - "SUPABASE_ANALYTICS_BACKEND", - config.analytics.backend, - projectEnvValues, - ); + const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); if (analyticsEnabled && analyticsBackend === "bigquery") { const gcpProjectId = envOverride( "SUPABASE_ANALYTICS_GCP_PROJECT_ID", diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 06ffb58d0b..8c8af6f57c 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { + LegacyInvalidAnalyticsBackendEnvOverrideError, LegacyInvalidBoolEnvOverrideError, LegacyInvalidJwtSecretError, LegacyInvalidPortEnvOverrideError, @@ -701,6 +702,22 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); + + // Go's `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects any + // `analytics.backend` value outside `postgres`/`bigquery` during the same + // `UnmarshalExact` decode every `SUPABASE_*` override goes through + // (`config.go:749-756`) — a malformed `SUPABASE_ANALYTICS_BACKEND` fails + // config loading outright, same mechanism as the port/bool overrides below. + it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidAnalyticsBackendEnvOverrideError, + ); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', + ); + }); }); describe("experimental.* (experimental.validate())", () => { @@ -1157,6 +1174,85 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("auth.email.smtp (present-table-implies-enabled)", () => { + // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` + // table is present but omits `enabled` (`pkg/config/config.go:743-748`), a + // presence-based default set on the raw viper map BEFORE the struct + // decodes — not a struct-tag default (Go's own zero-value is `false`). + // `@supabase/config`'s schema always decodes `smtp.enabled` to `false` when + // the key is absent, erasing that presence signal, so this check only runs + // when the raw `document` (5th param) is provided — same shape as the + // passkey/webauthn checks above. + it("rejects a present [auth.email.smtp] table with no fields", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { email: { smtp: {} } }, + }), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("rejects a present [auth.email.smtp] table missing port/user/pass/admin_email", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { email: { smtp: { host: "smtp.example.com" } } }, + }), + ).toThrow("Missing required field in config: auth.email.smtp.port"); + }); + + it("does not throw when [auth.email.smtp] explicitly sets enabled = false", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { email: { smtp: { enabled: false, host: "smtp.example.com" } } }, + }), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is a complete table", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { + email: { + smtp: { + host: "smtp.example.com", + port: 587, + user: "user", + pass: "pass", + admin_email: "admin@example.com", + }, + }, + }, + }), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is absent from the document", () => { + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { auth: {} }), + ).not.toThrow(); + }); + + it("does not throw a present [auth.email.smtp] table when no document is provided", () => { + // No `document` (5th param) at all — the same skip-rather-than-guess + // behavior every pre-existing call site/test in this file relies on. + const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw a present but incomplete [auth.email.smtp] table when auth is disabled", () => { + const config = baseConfig({ auth: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { + auth: { email: { smtp: { host: "smtp.example.com" } } }, + }), + ).not.toThrow(); + }); + }); + describe("auth.hook.* (URI/secret validation when enabled)", () => { // Go's `Config.Validate` runs `Auth.Hook.validate()` right after signing // keys/passkey validation, still inside `if c.Auth.Enabled` From 46916aac3dabe87ef04e82376ba61bda7bffdd56 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 19:28:30 +0100 Subject: [PATCH 67/81] fix(config): strip deprecated auth.external.{linkedin,slack} before schema decode (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's `(e external) validate()` (pkg/config/config.go:1418-1423) unconditionally deletes the deprecated `linkedin`/`slack` provider keys before the required-field loop runs, warning on stderr only when the deleted provider was enabled — a bare `[auth.external.slack] enabled = true` with no client_id/secret loads fine in Go. The shared schema still modeled `slack` as a fully-required provider (and had no `slack_oidc` entry at all, unlike `linkedin_oidc`), so `loadProjectConfig` rejected a config Go accepts. This affected every command that loads project config (status, stop, push, pull), not just status/stop. Adds `normalizeDeprecatedExternalProviders` in io.ts, mirroring the existing `normalizeDeprecatedSMTPSections`/`[inbucket]` precedent, and renames the `slack` provider to `slack_oidc` in the schema (config-sync/auth.sync.ts already had unreachable `slack_oidc` cases, confirming this was the intended id). Runs on the post-remote-merge document, not pre-merge like the SMTP normalizer, since Go's external.validate() runs once on the final effective config rather than eagerly over every [remotes.*] entry. --- packages/config/src/auth/providers.ts | 13 ++- packages/config/src/io.ts | 95 +++++++++++++++++++- packages/config/src/io.unit.test.ts | 119 ++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 4 deletions(-) diff --git a/packages/config/src/auth/providers.ts b/packages/config/src/auth/providers.ts index fcb059220d..4e7691d117 100644 --- a/packages/config/src/auth/providers.ts +++ b/packages/config/src/auth/providers.ts @@ -118,6 +118,15 @@ const provider = (providerConfig: { const defaultExternal = {}; +/** + * Go's deprecated `linkedin`/`slack` provider ids (`pkg/config/config.go:1418- + * 1423`) are intentionally NOT modeled here — only their `_oidc` replacements + * (`linkedin_oidc`, `slack_oidc`) are, matching Go's `(e external) validate()`, + * which unconditionally deletes the deprecated keys before anything decodes + * them. `io.ts`'s `normalizeDeprecatedExternalProviders` strips a config's + * `linkedin`/`slack` table (warning on stderr when it was `enabled`, same as + * Go) before this schema ever sees it. + */ export const external = Schema.Struct({ apple: provider({ id: "apple", @@ -185,8 +194,8 @@ export const external = Schema.Struct({ id: "x", name: "X", }), - slack: provider({ - id: "slack", + slack_oidc: provider({ + id: "slack_oidc", name: "Slack", }), spotify: provider({ diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 527df0bdb2..134f1982f2 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -373,6 +373,78 @@ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocum return { document: normalized, deprecatedSections }; } +interface NormalizedExternalProvidersDocument { + readonly document: unknown; + /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled` — drives the WARN. */ + readonly deprecatedProviders: ReadonlyArray; +} + +const DEPRECATED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; + +/** + * Go's `(e external) validate()` deprecated-provider handling + * (`apps/cli-go/pkg/config/config.go:1418-1423`): `linkedin`/`slack` are + * unconditionally deleted from `auth.external` before the required-field loop + * runs, so a bare `[auth.external.slack] enabled = true` with no + * `client_id`/`secret` loads fine in Go — a warning prints to stderr only + * when the deleted provider was `enabled`, never a hard failure. + * + * Unlike {@link normalizeDeprecatedSMTPSections}'s `[inbucket]` rename — which + * Go's own `normalizeDeprecatedSMTPConfig` runs BEFORE remote selection, over + * every `[remotes.*]` entry unconditionally (`config.go:594,614-640`) — Go's + * `external.validate()` runs from `Config.Validate()`, exactly ONCE on the + * final post-remote-merge struct (`config.go:882,1148`). A non-selected + * remote's own `auth.external.slack` block is never even looked at by Go. So + * this must run on the POST-merge document (`documentForDecode`, after + * `applyRemoteOverride`), not the pre-merge one: + * - the top-level `auth.external.{linkedin,slack}` is always stripped, and + * reported (for the caller to warn on) only when it was `enabled`, + * matching Go's single `external.validate()` call. + * - any `remotes.*.auth.external.{linkedin,slack}` still present (only + * possible when no remote matched `projectRef`, so `applyRemoteOverride` + * left `remotes` in place) is also stripped, but never reported — purely + * so `remoteProjectConfig`'s eager, whole-map schema decode + * (`packages/config/src/base.ts`) doesn't reject an unselected remote's + * deprecated block over a field Go itself never struct-decodes at all for + * a remote that isn't in effect. + */ +function normalizeDeprecatedExternalProviders( + document: unknown, +): NormalizedExternalProvidersDocument { + if (!isObject(document)) { + return { document, deprecatedProviders: [] }; + } + const normalized = { ...document }; + const deprecatedProviders: Array = []; + if (isObject(normalized.auth) && isObject(normalized.auth.external)) { + const external = { ...normalized.auth.external }; + for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { + const provider = external[ext]; + if (provider === undefined) continue; + if (isObject(provider) && provider.enabled === true) { + deprecatedProviders.push(ext); + } + delete external[ext]; + } + normalized.auth = { ...normalized.auth, external }; + } + if (isObject(normalized.remotes)) { + normalized.remotes = Object.fromEntries( + Object.entries(normalized.remotes).map(([name, remote]) => { + if (!isObject(remote) || !isObject(remote.auth) || !isObject(remote.auth.external)) { + return [name, remote]; + } + const external = { ...remote.auth.external }; + for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { + delete external[ext]; + } + return [name, { ...remote, auth: { ...remote.auth, external } }]; + }), + ); + } + return { document: normalized, deprecatedProviders }; +} + function getSchemaRef(document: unknown): string | undefined { if (!isObject(document)) { return undefined; @@ -483,7 +555,26 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( appliedRemote = resolved.appliedRemote; } - const config = yield* parseProjectConfig(documentForDecode, format, filePath); + // Strip Go's deprecated `auth.external.{linkedin,slack}` provider ids from + // the POST-remote-merge document, matching `external.validate()` running + // once on the final effective config (see `normalizeDeprecatedExternalProviders`). + const { document: normalizedForDecode, deprecatedProviders } = + normalizeDeprecatedExternalProviders(documentForDecode); + // Warn on stderr, matching Go's `external.validate()` (`config.go:1418-1423`). + // Go's own format string is a raw string literal ending in a literal + // backslash-n (raw string literals never process escapes, and `Fprintf` + // doesn't append a newline the way `Fprintln` does), so Go's actual stderr + // bytes have no real line break after this message — a library-internal + // artifact, not the parity-relevant part, same call already made for + // `LegacyInvalidPortEnvOverrideError` in the legacy shell. Not reproduced + // byte-for-byte; `Console.error` supplies a normal trailing newline instead. + for (const ext of deprecatedProviders) { + yield* Console.error( + `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, + ); + } + + const config = yield* parseProjectConfig(normalizedForDecode, format, filePath); return { path: filePath, @@ -491,7 +582,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( config, schemaRef: getSchemaRef(document), ignoredPaths: [], - document: isObject(documentForDecode) ? documentForDecode : undefined, + document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, appliedRemote, } satisfies LoadedProjectConfig; }); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 7965461b36..55b2bd8b11 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1377,3 +1377,122 @@ port = 54324 expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); }); }); + +describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { + let warnings: Array = []; + let errorSpy: ReturnType | undefined; + + function captureWarnings() { + warnings = []; + errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + } + + afterEach(() => { + errorSpy?.mockRestore(); + errorSpy = undefined; + }); + + async function loadToml(contents: string) { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(path, contents); + try { + return await runConfigEffect(loadProjectConfigFile(path)); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("loads a bare [auth.external.slack] block without required fields", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack] +enabled = true +`, + ); + + expect("slack" in loaded.config.auth.external).toBe(false); + expect(loaded.document).not.toHaveProperty("auth.external.slack"); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', + ), + ), + ).toBe(true); + }); + + test("loads a bare [auth.external.linkedin] block without required fields", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.linkedin] +enabled = true +`, + ); + + expect("linkedin" in loaded.config.auth.external).toBe(false); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "linkedin" provider. Please use [auth.external.linkedin_oidc] instead', + ), + ), + ).toBe(true); + }); + + test("does not warn when the deprecated section is present but disabled", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack] +enabled = false +`, + ); + + expect("slack" in loaded.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("does not warn when only [auth.external.slack_oidc] is used", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack_oidc] +enabled = true +client_id = "abc" +secret = "shh" +`, + ); + + expect(loaded.config.auth.external.slack_oidc.enabled).toBe(true); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("strips a deprecated [remotes.*.auth.external.slack] block without warning for an unselected remote", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[remotes.staging] +project_id = "stagingrefaaaaaaaaaa" + +[remotes.staging.auth.external.slack] +enabled = true +`, + ); + + // Not requesting `projectRef` means no remote is selected, so `remotes` survives + // decode verbatim (minus the deprecated key) rather than being merged/dropped. + expect(loaded.config.remotes.staging?.auth.external).not.toHaveProperty("slack"); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); +}); From 9518a79002d47eb7882edb997d9007d29d77ec50 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 19:56:15 +0100 Subject: [PATCH 68/81] fix(cli): backfill RSA CRT params so signing keys without dp/dq/qi verify (review: #PRRT_kwDOErm0O86OqN9V) Go's jwkToRSAPrivateKey (pkg/config/apikeys.go:132-168) builds the RSA private key from n/e/d/p/q alone and its stdlib derives dp/dq/qi lazily when absent, so a signing-keys JWK missing those CRT exponents still signs successfully in Go. Node's createPrivateKey({ format: "jwk" }) has no such fallback and hard-rejects the same key, so native status failed to generate local anon/service keys for a file the Go CLI accepts. Compute the missing dp/dq/qi via BigInt before handing the JWK to Node, matching Go's derivation. --- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 62 ++++++++++++++++++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 15 +++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index 6a4ddc5a47..a7b2768edf 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -67,6 +67,63 @@ export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role /** Go's asymmetric-JWT expiry: `time.Now().Add(time.Hour * 24 * 365 * 10)` (10 years). */ const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; +function base64UrlToBigInt(value: string): bigint { + const hex = Buffer.from(value, "base64url").toString("hex"); + return hex.length === 0 ? 0n : BigInt(`0x${hex}`); +} + +function bigIntToBase64Url(value: bigint): string { + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = `0${hex}`; + return Buffer.from(hex, "hex").toString("base64url"); +} + +/** Modular inverse of `a` mod `m` via the extended Euclidean algorithm (`a`/`m` coprime, as `q`/`p` always are for a valid RSA key). */ +function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [a, m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const quotient = oldR / r; + [oldR, r] = [r, oldR - quotient * r]; + [oldS, s] = [s, oldS - quotient * s]; + } + return ((oldS % m) + m) % m; +} + +/** + * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) Go's `jwkToRSAPrivateKey` + * (`apps/cli-go/pkg/config/apikeys.go:132-168`) never reads — it constructs + * `rsa.PrivateKey{N, E, D, Primes: [p, q]}` from `n`/`e`/`d`/`p`/`q` alone, and + * Go's stdlib `crypto/rsa` (`SignPKCS1v15` -> `precompute()`) lazily derives + * `Dp`/`Dq`/`Qinv` from `p`/`q`/`d` itself when they're absent, so a JWK + * missing them still signs successfully in Go. Node's + * `createPrivateKey({ format: "jwk" })` has no such fallback — it hard-rejects + * an RSA JWK without `dp`/`dq`/`qi` (`The "key.dp" property must be of type + * string`) — so this reproduces Go's derivation before handing the key to + * Node: `dp = d mod (p-1)`, `dq = d mod (q-1)`, `qi = q^-1 mod p` (RFC 7517 + * section 6.3.2 / RFC 3447 section 3.2). A key that already has all three (the common case + * for a Node/openssl-generated JWK) is returned unchanged; one missing + * `d`/`p`/`q` themselves is also returned unchanged — that's a genuinely + * invalid key in Go too, and `createPrivateKey` will raise its own error. + */ +function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { + if (jwk.dp !== undefined && jwk.dq !== undefined && jwk.qi !== undefined) { + return jwk; + } + if (jwk.d === undefined || jwk.p === undefined || jwk.q === undefined) { + return jwk; + } + const d = base64UrlToBigInt(jwk.d); + const p = base64UrlToBigInt(jwk.p); + const q = base64UrlToBigInt(jwk.q); + return { + ...jwk, + dp: bigIntToBase64Url(d % (p - 1n)), + dq: bigIntToBase64Url(d % (q - 1n)), + qi: bigIntToBase64Url(modInverse(q, p)), + }; +} + /** * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK @@ -124,7 +181,10 @@ export function legacyGenerateAsymmetricGoJwt( ); const data = `${headerEncoded}.${payloadEncoded}`; - const privateKey = createPrivateKey({ key: jwk, format: "jwk" }); + const privateKey = createPrivateKey({ + key: algorithm === "RS256" ? ensureRsaCrtParams(jwk) : jwk, + format: "jwk", + }); const signature = algorithm === "RS256" ? createSign("RSA-SHA256").update(data).end().sign(privateKey) diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index 095ec1480f..5bfeabfe70 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -96,6 +96,21 @@ describe("legacyGenerateAsymmetricGoJwt", () => { expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); }); + it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { + // Go's `jwkToRSAPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:132-168`) + // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` + // alone, and Go's stdlib derives the CRT params itself when absent. A + // hand-authored signing-keys file that omits them (common — RFC 7517 marks + // them optional) must still sign successfully here. + const jwk = generateRsaJwk("rsa-kid"); + const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; + const token = legacyGenerateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + it("signs and verifies an ES256 token from an EC JWK", async () => { const jwk = generateEcJwk("ec-kid"); const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role"); From c902298bf022a985cf53042735ed44eea14eab39 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 19:56:23 +0100 Subject: [PATCH 69/81] fix(cli): resolve env() indirection inside SUPABASE_* overrides (review: #PRRT_kwDOErm0O86OqN9Z) Go's LoadEnvHook is the first mapstructure decode hook composed into UnmarshalExact (config.go:749-753,769-772), so it resolves a nested env(VAR) reference on any string mapstructure decodes into the struct, including a SUPABASE_* AutomaticEnv override string itself, not just a config.toml literal. envOverride returned that raw override string verbatim, so e.g. SUPABASE_API_ENABLED=env(API_ENABLED) was rejected as a malformed bool instead of resolving to the referenced value. Resolve one level of env(VAR) indirection with the same projectEnvValues/ process.env precedence and non-empty gate as the outer lookup, reusing packages/config's ENV_CAPTURE_REGEX (now re-exported) rather than duplicating the pattern. --- .../shared/legacy-local-config-values.ts | 21 ++++++- .../legacy-local-config-values.unit.test.ts | 58 +++++++++++++++++++ packages/config/src/index.ts | 1 + 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index bd568349cc..479b5ce497 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; -import type { ProjectConfig } from "@supabase/config"; +import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; import { Schema } from "effect"; @@ -166,6 +166,19 @@ function envOverridePort( * that already-resolved map (see `legacyResolveProjectEnvironmentValues`); * falling back to `process.env` covers the "no `supabase/` project found" * case, where `projectEnvValues` is `undefined`. + * + * The resolved override string itself can be a further `env(VAR)` indirection + * (e.g. `SUPABASE_API_ENABLED=env(API_ENABLED)`) — Go's `LoadEnvHook` + * (`decode_hooks.go:15-23`) is the first mapstructure decode hook composed + * into `v.UnmarshalExact` (`config.go:749-753,769-772`), so it resolves + * `env(...)` on every string mapstructure decodes into the struct, regardless + * of whether Viper sourced that string from `config.toml` or a `SUPABASE_*` + * `AutomaticEnv` override (`config.go:582-586`) — Viper's `Get()` just returns + * a string; the hook chain doesn't know or care where it came from. Resolved + * with the same `projectEnvValues ?? process.env` precedence and non-empty + * gate as the outer lookup (mirroring `decode_hooks.go:19-24`'s `len(env) > 0` + * check); an unresolved/empty indirection leaves the `env(VAR)` literal + * untouched, same as Go. */ function envOverride( name: string, @@ -173,7 +186,11 @@ function envOverride( projectEnvValues: Readonly> | undefined, ): string | undefined { const value = projectEnvValues?.[name] ?? process.env[name]; - return value !== undefined && value.length > 0 ? value : configured; + 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]; + return resolved !== undefined && resolved.length > 0 ? resolved : value; } /** diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 8c8af6f57c..3983ec997d 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -264,6 +264,64 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:15-23`) is + // the first mapstructure decode hook composed into `v.UnmarshalExact` + // (`config.go:749-753,769-772`), so it resolves a nested `env(VAR)` + // reference on ANY string mapstructure decodes into the struct — including + // a `SUPABASE_*` env-override value itself, not just a `config.toml` + // literal. `envOverride`'s callers (string/port/bool fields) must all see + // that same resolution. + 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"]; + }); + + 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); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(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"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + // If the bool override weren't resolved through the indirection, the + // literal "env(INDIRECT_API_ENABLED)" string would fail Go's + // strconv.ParseBool acceptance set and throw LegacyInvalidBoolEnvOverrideError; + // resolving it to "false" disables api.enabled and skips the TLS check + // that would otherwise throw on the missing cert file. + expect(() => legacyResolveLocalConfigValues(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)"; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Go's LoadEnvHook only substitutes when the target var is non-empty + // (`decode_hooks.go:19-24`) — an unset indirection leaves the literal + // `env(VAR)` string, same as an unresolved config.toml-level reference. + expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); + }); + }); + describe("non-auth SUPABASE_* env overrides", () => { // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() // generically across the whole config struct (pkg/config/config.go:529-535), diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 319689810b..f734e68be7 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -40,3 +40,4 @@ export { projectConfigStoreLayer } from "./project-config.layer.ts"; export { ProjectConfigStore } from "./project-config.service.ts"; export { PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; export { KONG_LOCAL_CA_CERT } from "./tls.ts"; +export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; From cd1b7c3cc3dd89269b67b68069e5e6922e0334a3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 19:56:30 +0100 Subject: [PATCH 70/81] fix(config): match remotes before env() interpolation, not after (review: #PRRT_kwDOErm0O86OrVR-, #PRRT_kwDOErm0O86OrzAH) Go's loadFromFile duplicate-check/selection loop over [remotes.*] reads viper's raw string values (config.go:596-610) and only resolves env(...) afterward, inside the struct decode's LoadEnvHook (config.go:611,749-753, decode_hooks.go:13-26). loadProjectConfigFile instead ran the match/merge against the already-interpolated document, so a [remotes.x] project_id = "env(REF)" would match a caller-supplied, already-resolved REF even though Go compares the literal env(REF) string and never selects that remote. Run the duplicate-check and match/merge against the raw, pre-interpolation document, then interpolate the merged result for the final decode. The project_id FORMAT check (Go's post-decode Config.Validate) still uses the interpolated remotes, since that check genuinely runs after env() resolution in Go. --- packages/config/src/io.ts | 102 +++++++++++++++++++++------- packages/config/src/io.unit.test.ts | 60 ++++++++++++++++ 2 files changed, 136 insertions(+), 26 deletions(-) diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 134f1982f2..d19c4abd48 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -179,6 +179,15 @@ const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; * project ref, mirroring Go's `Config.Validate` (`config.go:996-1001`) — that * loop runs unconditionally over every remote on every config load, not only * the one that ends up selected/merged, so this always runs too. + * + * Unlike {@link checkDuplicateRemoteProjectIds}/the match below (which read + * viper's raw, pre-`LoadEnvHook` values — see {@link applyRemoteOverride}'s + * doc comment), `Config.Validate` runs entirely AFTER the struct decode + * (`config.go:882`), by which point `LoadEnvHook` has already resolved every + * `env(...)` reference (`config.go:749-753`). So this check must see the + * already-interpolated `project_id`, not the literal `env(REF)` form — an + * `[remotes.x] project_id = "env(REF)"` that resolves to a valid 20-letter ref + * passes here even though the raw string doesn't match the pattern itself. */ const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record) { for (const [remoteName, remote] of Object.entries(remotes)) { @@ -194,35 +203,51 @@ const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record< /** * Applies the `[remotes.]` override whose `project_id` matches `projectRef` - * to `document`, mirroring Go's `loadFromFile` remote resolution - * (`config.go:503-518`). Returns the merged document (with `remotes` stripped) and + * to `rawDocument`, mirroring Go's `loadFromFile` remote resolution + * (`config.go:503-518`). Returns the merged document (with `remotes` stripped, + * still pre-`env()`-interpolation — the caller re-interpolates the result) and * the matched remote name. `projectRef` of `undefined` never matches any remote * (including one that itself omits `project_id`, which reads as `""`) — callers - * that don't request a specific remote get the duplicate check below without - * the merge, so the base document loads verbatim as before. + * that don't request a specific remote get the duplicate/format checks below + * without the merge, so the base document loads verbatim as before. + * + * `rawDocument`'s `remotes` block is the PRE-interpolation document: Go's + * duplicate-check/selection loop in `loadFromFile` reads directly off viper's + * raw config values (`v.GetString(fmt.Sprintf("remotes.%s.project_id", name))`, + * `config.go:596-610`) and only calls `c.load(v)` — which resolves `env(...)` + * via `LoadEnvHook` during the struct decode (`config.go:749-753`, + * `decode_hooks.go:13-26`) — afterward (`config.go:611`). So a + * `[remotes.prod] project_id = "env(REF)"` is matched/deduped against the + * LITERAL `env(REF)` string in Go, never against `REF`'s resolved value; this + * mirrors that exactly rather than matching post-interpolation, which would + * merge a remote Go itself would never select. `interpolatedRemotes` (Go's + * post-decode `c.Remotes`, mirrored here as the already-interpolated + * `remotes` subtree) is used only for {@link checkRemoteProjectIdFormat} — see + * its doc comment for why that check needs the resolved value instead. */ const applyRemoteOverride = Effect.fnUntraced(function* ( - document: Record, + rawDocument: Record, + interpolatedRemotes: Record | undefined, projectRef: string | undefined, ) { - const remotes = document["remotes"]; + const remotes = rawDocument["remotes"]; if (!isObject(remotes)) { - return { document, appliedRemote: undefined as string | undefined }; + return { document: rawDocument, appliedRemote: undefined as string | undefined }; } yield* checkDuplicateRemoteProjectIds(remotes); - yield* checkRemoteProjectIdFormat(remotes); + yield* checkRemoteProjectIdFormat(interpolatedRemotes ?? remotes); const name = Object.entries(remotes).find(([, remote]) => { const projectId = isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; return projectRef !== undefined && projectId === projectRef; })?.[0]; if (name === undefined) { - return { document, appliedRemote: undefined as string | undefined }; + return { document: rawDocument, appliedRemote: undefined as string | undefined }; } const remoteSubtree = remotes[name]; let merged = isObject(remoteSubtree) - ? mergeRemoteSubtree(document, remoteSubtree) - : { ...document }; + ? mergeRemoteSubtree(rawDocument, remoteSubtree) + : { ...rawDocument }; if (!(isObject(remoteSubtree) && remoteSetsDbSeedEnabled(remoteSubtree))) { merged = withDbSeedDisabled(merged); } @@ -535,26 +560,51 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( baseEnv: process.env, search: options?.search, })); - const interpolated = interpolateEnvReferencesAgainstSchema( - normalized, - projectEnv?.values ?? {}, - ProjectConfigSchema, - ); - - // Merge the matching `[remotes.*]` override over the base document before - // decode (Go's `loadFromFile` with `Config.ProjectId` set). Runs - // unconditionally so the duplicate-`project_id` check always applies, like - // Go's own loop (`config.go:594-602`), even for callers that don't request - // a `projectRef` — those just never match a remote, so the base document - // still loads verbatim, only now with the duplicate check applied too. - let documentForDecode: unknown = interpolated; + const interpolateDocument = (document: unknown): unknown => + interpolateEnvReferencesAgainstSchema(document, projectEnv?.values ?? {}, ProjectConfigSchema); + + // Interpolated once here purely to give `applyRemoteOverride`'s FORMAT check + // (not its match/merge — see that function's doc comment) the resolved + // `remotes.*.project_id`, matching Go's post-decode `Config.Validate`. + const interpolatedForValidation = interpolateDocument(normalized); + const interpolatedRemotes = + isObject(interpolatedForValidation) && isObject(interpolatedForValidation["remotes"]) + ? interpolatedForValidation["remotes"] + : undefined; + + // Merge the matching `[remotes.*]` override over the RAW (pre-`env()`- + // interpolation) document — Go's `loadFromFile` duplicate-check/selection + // loop runs on viper's raw string values, before `LoadEnvHook` ever resolves + // `env(...)` (`config.go:594-611`, `decode_hooks.go:13-26`); see + // `applyRemoteOverride`'s doc comment. Runs unconditionally so the + // duplicate-`project_id` check always applies, like Go's own loop, even for + // callers that don't request a `projectRef` — those just never match a + // remote, so the base document still loads verbatim, only now with the + // duplicate check applied too. + let documentForDecode: unknown = normalized; let appliedRemote: string | undefined; - if (isObject(interpolated)) { - const resolved = yield* applyRemoteOverride(interpolated, options?.projectRef); + if (isObject(normalized)) { + const resolved = yield* applyRemoteOverride( + normalized, + interpolatedRemotes, + options?.projectRef, + ); documentForDecode = resolved.document; appliedRemote = resolved.appliedRemote; } + // The merge above ran on the raw document, so any `env(...)` reference in + // the winning remote's subtree (or elsewhere in the base) still needs + // resolving before decode — mirrors Go's `LoadEnvHook` running on the + // post-merge viper store inside `c.load(v)`. When no remote matched, this + // recomputes the same substitutions `interpolatedForValidation` already + // made (documentForDecode is just `normalized` again) — a redundant walk on + // that path, but correctness on the match+`env()` path matters more than + // avoiding it. + documentForDecode = isObject(documentForDecode) + ? interpolateDocument(documentForDecode) + : documentForDecode; + // Strip Go's deprecated `auth.external.{linkedin,slack}` provider ids from // the POST-remote-merge document, matching `external.validate()` running // once on the final effective config (see `normalizeDeprecatedExternalProviders`). diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 55b2bd8b11..831b4758d4 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1218,6 +1218,66 @@ enabled = true } }); + test("does not match a remote whose project_id is env(REF) against the resolved ref (Go parity)", async () => { + // Go's `loadFromFile` duplicate-check/selection loop reads viper's RAW + // string values (`config.go:596-610`) and only calls `c.load(v)` — which + // resolves `env(...)` via `LoadEnvHook` — afterward (`config.go:611`, + // `decode_hooks.go:13-26`). So a `[remotes.x] project_id = "env(REF)"` + // never matches a caller-supplied, already-resolved `REF`: Go compares the + // literal `env(REF)` string, not what it resolves to. + const previous = process.env.SUPABASE_REMOTE_ENV_REF_TEST; + process.env.SUPABASE_REMOTE_ENV_REF_TEST = PREVIEW_REF; + const cwd = await writeTomlProject(`project_id = "baseref" + +[api] +max_rows = 1 + +[remotes.preview] +project_id = "env(SUPABASE_REMOTE_ENV_REF_TEST)" +[remotes.preview.api] +max_rows = 999 +`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.api.max_rows).toBe(1); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_REMOTE_ENV_REF_TEST; + } else { + process.env.SUPABASE_REMOTE_ENV_REF_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("validates a remote's env(REF) project_id format against its resolved value, not the literal", async () => { + // Go's `Config.Validate` (`config.go:989-1001`) runs entirely after the + // struct decode, by which point `LoadEnvHook` has already resolved + // `env(...)` — so it validates the RESOLVED project_id against the + // 20-lowercase-letter pattern, not the literal `env(REF)` string (which + // would never match the pattern itself). + const previous = process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; + process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = PREVIEW_REF; + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.preview] +project_id = "env(SUPABASE_REMOTE_ENV_REF_FORMAT_TEST)" +`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; + } else { + process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolves env() references inside the matching remote before merge", async () => { const previous = process.env.SUPABASE_REMOTE_MAX_ROWS_TEST; process.env.SUPABASE_REMOTE_MAX_ROWS_TEST = "777"; From b15c7dd935711d5777c651a1180fde6c6f657cb8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 19:56:37 +0100 Subject: [PATCH 71/81] fix(cli): resolve status host from the active Docker context (review: #PRRT_kwDOErm0O86OrVSC) Go's Docker.DaemonHost() (internal/utils/docker.go:41-54) comes from a client built via cli.Initialize(), whose endpoint resolution walks DOCKER_HOST -> DOCKER_CONTEXT -> the config file's currentContext -> the context store (docker/cli's getDockerEndPoint/resolveContextName) - not just DOCKER_HOST. legacyGetHostname only checked SUPABASE_SERVICES_HOSTNAME/DOCKER_HOST and fell back to 127.0.0.1, so with an active non-default Docker context and no DOCKER_HOST set, native status could correctly inspect a remote daemon (the docker/ podman binary its ps/inspect calls shell out to already honors the context) while printing unusable localhost API/DB/Studio URLs for it. Resolve the active context's tcp:// endpoint from $DOCKER_CONFIG/config.json and the context store's meta.json before falling back to the loopback default, mirroring Go's precedence. --- apps/cli/src/legacy/shared/legacy-hostname.ts | 136 ++++++++++++++++-- .../shared/legacy-hostname.unit.test.ts | 124 +++++++++++++++- 2 files changed, 244 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 087363c20b..d2c6d719cd 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -1,17 +1,128 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + const LOCAL_HOST = "127.0.0.1"; +/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ +const DEFAULT_CONTEXT_NAME = "default"; + +/** + * Docker CLI's config directory: `$DOCKER_CONFIG` or `~/.docker` + * (`docker/cli` `cliconfig.Dir()`), read from here rather than + * `client.Client`'s own resolution since this module never spawns a real + * Docker client — it only needs the same two on-disk files that resolution + * reads. + */ +function dockerConfigDir(): string { + const override = process.env["DOCKER_CONFIG"]; + return override !== undefined && override.length > 0 ? override : join(homedir(), ".docker"); +} + +/** + * Go's `cli.CurrentContext()` name resolution (`docker/cli` + * `cli/command/cli.go`'s `resolveContextName`): `DOCKER_CONTEXT` env, else the + * config file's `currentContext`, else `"default"`. Only reached when + * `DOCKER_HOST` is unset — `resolveContextName` itself forces `"default"` + * when `DOCKER_HOST`/`--host` is set, which {@link legacyGetHostname} below + * already handles as its own, earlier branch. + */ +function currentDockerContextName(): string { + const fromEnv = process.env["DOCKER_CONTEXT"]; + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + try { + const config = JSON.parse(readFileSync(join(dockerConfigDir(), "config.json"), "utf8")) as { + currentContext?: unknown; + }; + if (typeof config.currentContext === "string" && config.currentContext.length > 0) { + return config.currentContext; + } + } catch { + // Missing/malformed config.json → the default context, same as Go's own + // silent fallback when it can't load the config file here. + } + return DEFAULT_CONTEXT_NAME; +} + +/** + * Reads a non-default context's daemon endpoint from Docker CLI's context + * store: `/contexts/meta//meta.json`'s + * `Endpoints.docker.Host` (`docker/cli` `cli/context/store/metadatastore.go`). + * The `"default"` context has no store entry (it's Go's synthetic + * always-available context, resolved without a client, see + * `cli.Initialize`), so it's never looked up here — matching the earlier + * `"default"` short-circuit in {@link currentDockerContextName}'s caller. + */ +function dockerContextEndpointHost(contextName: string): string | undefined { + if (contextName === DEFAULT_CONTEXT_NAME) { + return undefined; + } + try { + const contextId = createHash("sha256").update(contextName).digest("hex"); + const metaPath = join(dockerConfigDir(), "contexts", "meta", contextId, "meta.json"); + const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { + readonly Endpoints?: { readonly docker?: { readonly Host?: unknown } }; + }; + const host = meta.Endpoints?.docker?.Host; + return typeof host === "string" && host.length > 0 ? host : undefined; + } catch { + // Missing/malformed context store entry → treat as unresolvable, same as + // Go silently falling back to the loopback default below. + return undefined; + } +} + +/** + * Extracts the bare host from a `tcp://host:port` daemon endpoint, mirroring + * Go's `client.ParseHostURL` + `net.SplitHostPort` (`misc.go:307`). Returns + * `undefined` for a non-`tcp://` endpoint (e.g. `unix://`, `npipe://`) or an + * unparseable one, in which case the caller falls back to the loopback + * default, matching Go's `net.SplitHostPort` failure/non-TCP handling. + */ +function hostFromTcpEndpoint(endpoint: string): string | undefined { + try { + const url = new URL(endpoint); + if (url.protocol !== "tcp:" || url.hostname.length === 0) { + return undefined; + } + // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's + // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a + // single surrounding bracket pair so local-stack probes dial/compare the + // same host Go does; IPv4 and named hosts are returned unchanged. + const host = url.hostname; + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + } catch { + return undefined; + } +} + /** * Resolves the hostname used for local Supabase service connections, mirroring - * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298`): + * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298-311`): * * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when * the Docker daemon is not reachable on the container's own loopback. * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint * (Go's `Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. `127.0.0.1` otherwise (the default unix-socket daemon). + * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a + * `tcp://` one — Go's `Docker.DaemonHost()` comes from a client built via + * `command.NewDockerCli()` + `cli.Initialize()` (`apps/cli-go/internal/ + * utils/docker.go:41-54`), whose endpoint resolution walks `DOCKER_HOST` -> + * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context + * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ + * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` + * binary this module's callers shell out to for `ps`/`inspect` already + * resolves the same active context itself, so without this step `status` + * could correctly inspect a remote daemon while printing unusable + * `127.0.0.1` API/DB/Studio URLs for it. + * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an + * unresolvable/malformed context). * * Shared across legacy commands that connect to the local stack (`gen types`, - * `test db`, and later `db reset` / `db dump`). + * `test db`, `status`, `stop`, and later `db reset` / `db dump`). */ export function legacyGetHostname(): string { const override = process.env["SUPABASE_SERVICES_HOSTNAME"]; @@ -20,18 +131,13 @@ export function legacyGetHostname(): string { } const dockerHost = process.env["DOCKER_HOST"]; if (dockerHost !== undefined && dockerHost.length > 0) { - try { - const url = new URL(dockerHost); - if (url.protocol === "tcp:" && url.hostname.length > 0) { - // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's - // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a - // single surrounding bracket pair so local-stack probes dial/compare the - // same host Go does; IPv4 and named hosts are returned unchanged. - const host = url.hostname; - return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - } - } catch { - // Unparseable DOCKER_HOST → fall through to the loopback default. + return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST; + } + const contextEndpoint = dockerContextEndpointHost(currentDockerContextName()); + if (contextEndpoint !== undefined) { + const host = hostFromTcpEndpoint(contextEndpoint); + if (host !== undefined) { + return host; } } return LOCAL_HOST; diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 9706f5f002..0f1c351d0e 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { legacyGetHostname } from "./legacy-hostname.ts"; @@ -19,6 +23,30 @@ function withEnv(entries: Record, run: () => T): } } +/** Writes a Docker CLI-shaped `$DOCKER_CONFIG` directory (`config.json` + a context store entry). */ +function writeDockerConfigDir(options: { + readonly currentContext?: string; + readonly contexts?: Readonly>; // context name -> docker.Host endpoint +}): string { + const dir = mkdtempSync(join(tmpdir(), "legacy-hostname-docker-config-")); + if (options.currentContext !== undefined) { + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ currentContext: options.currentContext }), + ); + } + for (const [name, host] of Object.entries(options.contexts ?? {})) { + const contextId = createHash("sha256").update(name).digest("hex"); + const metaDir = join(dir, "contexts", "meta", contextId); + mkdirSync(metaDir, { recursive: true }); + writeFileSync( + join(metaDir, "meta.json"), + JSON.stringify({ Endpoints: { docker: { Host: host } } }), + ); + } + return dir; +} + describe("legacyGetHostname", () => { it("prefers SUPABASE_SERVICES_HOSTNAME over everything else", () => { expect( @@ -63,4 +91,98 @@ describe("legacyGetHostname", () => { withEnv({ SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: undefined }, legacyGetHostname), ).toBe("127.0.0.1"); }); + + describe("active Docker context resolution (Go's Docker.DaemonHost() parity)", () => { + let configDirs: Array = []; + + afterEach(() => { + for (const dir of configDirs) rmSync(dir, { recursive: true, force: true }); + configDirs = []; + }); + + function withDockerConfig( + options: Parameters[0], + env: Record, + run: () => T, + ): T { + const dir = writeDockerConfigDir(options); + configDirs.push(dir); + return withEnv( + { + SUPABASE_SERVICES_HOSTNAME: undefined, + DOCKER_HOST: undefined, + DOCKER_CONFIG: dir, + ...env, + }, + run, + ); + } + + it("resolves the host from the active context's tcp:// endpoint via config.json's currentContext", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("remote-host"); + }); + + it("prefers DOCKER_CONTEXT over config.json's currentContext", () => { + const result = withDockerConfig( + { + currentContext: "other", + contexts: { envctx: "tcp://envctx-host:2375", other: "tcp://other-host:2375" }, + }, + { DOCKER_CONTEXT: "envctx" }, + legacyGetHostname, + ); + expect(result).toBe("envctx-host"); + }); + + it("strips brackets from an IPv6 context endpoint (net.SplitHostPort parity)", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://[::1]:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("::1"); + }); + + it("falls back to 127.0.0.1 when the active context's endpoint is not tcp://", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "unix:///var/run/docker.sock" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when the context store entry is missing", () => { + const result = withDockerConfig({ currentContext: "ghost" }, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when config.json is missing entirely (default context)", () => { + const result = withDockerConfig({}, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("never consults the context store for the default context", () => { + const result = withDockerConfig( + { currentContext: "default", contexts: { default: "tcp://should-never-be-read:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("DOCKER_HOST still takes precedence over an active non-default context", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://context-host:2375" } }, + { DOCKER_HOST: "tcp://direct-host:2375" }, + legacyGetHostname, + ); + expect(result).toBe("direct-host"); + }); + }); }); From 5907b697178b4c42f4d19e7560b3d0bd73da143b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 20:24:39 +0100 Subject: [PATCH 72/81] fix(cli): skip supabase/.env.local under SUPABASE_ENV=test to match Go (review: #PRRT_kwDOErm0O86OrzAK) Go's loadDefaultEnv (apps/cli-go/pkg/config/config.go:1243-1250) omits .env.local from its candidate dotenv list whenever SUPABASE_ENV=test, so a malformed or intentionally non-test supabase/.env.local is invisible to Go in that mode. status/stop's preliminary loadProjectEnvironment call had no such gate and its parse failure propagates as a real command failure, unlike the later legacyResolveProjectEnvironmentValues pass which already re-derives the file list per SUPABASE_ENV. Add an opt-in skipEnvLocal option to loadProjectEnvironment (default off, so next/ and secrets set are unaffected) and pass it from both status.handler.ts and stop.handler.ts when SUPABASE_ENV=test. --- .../legacy/commands/status/status.handler.ts | 9 ++++++ .../src/legacy/commands/stop/stop.handler.ts | 9 ++++++ packages/config/src/project.ts | 13 ++++++++- packages/config/src/project.unit.test.ts | 29 +++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 77b1f461fe..71d1fad13e 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -140,6 +140,15 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS cwd: cliConfig.workdir, baseEnv: process.env, search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", }).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index e90fe5fd20..d32e304484 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -99,6 +99,15 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject cwd: cliConfig.workdir, baseEnv: process.env, search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", }).pipe( Effect.mapError( (cause) => diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index a937bab5db..bbf1ae056c 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -184,6 +184,17 @@ export interface LoadProjectEnvironmentOptions { readonly baseEnv?: Readonly>; /** See {@link FindProjectPathsOptions.search}. */ readonly search?: boolean; + /** + * Skip reading/parsing `paths.envLocalPath` (`supabase/.env.local`) + * entirely. Mirrors Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/ + * config.go:1243-1250`), which omits `.env.local` from its candidate + * filename list whenever `SUPABASE_ENV=test` — so a malformed or + * intentionally non-test `.env.local` is invisible to Go in that mode and + * must not fail config loading here either. Defaults to `false` so + * existing callers that don't have a `SUPABASE_ENV` gate of their own + * (`next/`, `secrets set`) are unaffected. + */ + readonly skipEnvLocal?: boolean; } export const loadProjectEnvironment = Effect.fnUntraced(function* ( @@ -207,7 +218,7 @@ export const loadProjectEnvironment = Effect.fnUntraced(function* ( loadedPaths.push(paths.envPath); } - if (yield* fs.exists(paths.envLocalPath)) { + if (!options.skipEnvLocal && (yield* fs.exists(paths.envLocalPath))) { const contents = yield* fs.readFileString(paths.envLocalPath); const parsed = yield* parseDotEnv(paths.envLocalPath, contents); applySource(values, sources, parsed, ".env.local"); diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index 90a584ae10..da677db3b1 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -212,6 +212,35 @@ describe("project discovery and lazy env resolution", () => { } }); + test("skipEnvLocal ignores .env.local entirely, matching Go's SUPABASE_ENV=test gate", async () => { + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) omits + // `.env.local` from its candidate filename list whenever `SUPABASE_ENV=test`, + // so a malformed `.env.local` is invisible to Go in that mode. Callers that + // reproduce this gate (`status`/`stop` handlers) pass `skipEnvLocal: true`. + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile(join(cwd, "supabase", ".env"), "FROM_ENV=1\n"); + // Malformed — would normally throw ProjectEnvParseError. + await writeFile(join(cwd, "supabase", ".env.local"), "!!!not-a-valid-line\n"); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd, skipEnvLocal: true })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.FROM_ENV).toBe("1"); + expect(projectEnv?.loadedPaths).toEqual([join(cwd, "supabase", ".env")]); + + // Without the flag, the same malformed file still fails as before. + await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( + ProjectEnvParseError, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); From 09e218a2e474da77ab4d410a15bbfe8813dd817c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 6 Jul 2026 20:24:54 +0100 Subject: [PATCH 73/81] fix(config): split string values into slices for array-typed config fields (review: #PRRT_kwDOErm0O86OrzAR) Go's newDecodeHook (apps/cli-go/pkg/config/config.go:775-784) wires mapstructure.StringToSliceHookFunc(",") unconditionally into config decode, so a plain string value for a []string field (e.g. auth.additional_redirect_urls = "http://a,http://b") decodes fine in Go, whether the string is a literal TOML value or an env()-substituted one. @supabase/config's schema had no equivalent and rejected any string value for an array-typed field outright. Extend the existing pre-decode env() coercion walker (interpolateEnvReferencesAgainstSchema) with an "array" ExpectedType for homogeneous string arrays, and apply it to any string leaf at that path (not just env()-substituted ones, since Go's hook fires regardless of value source) by splitting on "," -- matching Go's StringToSliceHookFunc exactly, including its empty-string-to-empty- slice case. --- packages/config/src/io.unit.test.ts | 89 +++++++++++++++++++++++++++++ packages/config/src/lib/env.ts | 60 ++++++++++++++++--- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 831b4758d4..32180b5ab7 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -814,6 +814,95 @@ enabled = "env(SUPABASE_ANALYTICS_ENABLED)" }, ); + test("splits a comma-separated string literal into a slice (Go's StringToSliceHookFunc)", async () => { + // Go's `newDecodeHook` (`apps/cli-go/pkg/config/config.go:775-784`) wires + // `mapstructure.StringToSliceHookFunc(",")` unconditionally, so a plain + // string value for a `[]string` field like `additional_redirect_urls` + // decodes fine in Go — not just via `env(...)`. + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("splits an env()-substituted comma-separated string into a slice", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "env(SUPABASE_REDIRECT_URLS)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_REDIRECT_URLS=http://a,http://b\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("an empty string literal for a slice field decodes to an empty array", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.additional_redirect_urls).toEqual([]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("an actual array value for a slice field is left untouched", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = ["http://a", "http://b"] +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("preserves env() literals on string fields when the var is unset (Go parity)", async () => { const cwd = makeTempProject(); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index fb38282721..1e099d2e40 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -58,12 +58,19 @@ export const secret = (annotations?: SecretAnnotations) => // and the value is still a string, coerce it. This mirrors Go's // mapstructure chain where `LoadEnvHook` returns a string and subsequent // hooks convert it to the target type. -// - Coercion is only attempted on strings produced by env() substitution. -// Pre-existing string literals at non-string paths are left untouched — -// they'll surface as schema errors at decode time with their original -// value, preserving error clarity. +// - Number/boolean coercion is only attempted on strings produced by env() +// substitution. Pre-existing string literals at non-string paths are left +// untouched — they'll surface as schema errors at decode time with their +// original value, preserving error clarity. +// - Array coercion is the one exception: if the schema at that path expects +// a homogeneous string array, ANY string leaf (substituted or a plain +// literal) is split on `,` — mirroring Go's `StringToSliceHookFunc(",")` +// (`apps/cli-go/pkg/config/config.go:775-784`), which is wired +// unconditionally into the decode hook chain regardless of where the +// string came from (e.g. `additional_redirect_urls = "http://a,http://b"` +// decodes fine in Go today, not just via `env(...)`). -type ExpectedType = "number" | "boolean" | "string" | "unknown"; +type ExpectedType = "number" | "boolean" | "string" | "array" | "unknown"; // Go decodes an env()-substituted boolean via mapstructure's weakly-typed // `decodeBool`, which runs `strconv.ParseBool` on the string — a wider @@ -85,6 +92,20 @@ function unwrapAst(ast: SchemaAST.AST): SchemaAST.AST { return ast; } +// A homogeneous `Schema.Array(Schema.String)` compiles to an `Arrays` AST +// node with no fixed tuple `elements` and a single `rest` spread type. Only +// this shape (not a fixed string tuple, and not a mixed-type array) is +// eligible for Go's `StringToSliceHookFunc(",")` coercion below — mirroring +// that Go itself only wires the hook for `[]string`-kind targets +// (`apps/cli-go/pkg/config/config.go:775-784`), not fixed-arity tuples. +function isHomogeneousStringArray(node: SchemaAST.AST): boolean { + if (node._tag !== "Arrays" || node.elements.length !== 0 || node.rest.length !== 1) { + return false; + } + const spread = node.rest[0]; + return spread !== undefined && unwrapAst(spread)._tag === "String"; +} + function leafExpectedType(ast: SchemaAST.AST): ExpectedType { const node = unwrapAst(ast); switch (node._tag) { @@ -94,6 +115,8 @@ function leafExpectedType(ast: SchemaAST.AST): ExpectedType { return "boolean"; case "String": return "string"; + case "Arrays": + return isHomogeneousStringArray(node) ? "array" : "unknown"; case "Union": { // Walk Union branches in declared order; first concrete primitive wins. // For unions like `Schema.Union(Schema.Number, Schema.Null)` this picks @@ -176,6 +199,13 @@ function coerceLeaf(value: unknown, expected: ExpectedType): unknown { if (GO_BOOL_FALSE.has(value)) return false; return value; } + if (expected === "array") { + // Go's `mapstructure.StringToSliceHookFunc(",")` (wired in + // `apps/cli-go/pkg/config/config.go:775-784`): an empty string decodes to + // an empty slice, otherwise the string is split on the separator with no + // further trimming of the resulting elements. + return value === "" ? [] : value.split(","); + } return value; } @@ -241,18 +271,34 @@ function walk( if (ast !== null && isDeferredEnvField(ast)) { return document; } + + const substituted = substituteEnvLeaf(document, env); + const expected = ast === null ? "unknown" : leafExpectedType(ast); + + // Go's `StringToSliceHookFunc(",")` (`apps/cli-go/pkg/config/config.go: + // 775-784`) is wired unconditionally into `v.UnmarshalExact`'s decode + // hook chain, so it splits ANY string being decoded into a `[]string` + // field — a plain TOML literal (`additional_redirect_urls = "a,b"`) just + // as much as an `env()`-substituted one. Unlike the number/boolean + // coercion below (scoped to substituted values only, since TOML already + // decodes literal numbers/booleans to their native type), array coercion + // must also apply to literal strings that never went through + // `substituteEnvLeaf`. + if (expected === "array") { + return coerceLeaf(substituted, expected); + } + // Substitute env() then coerce based on the schema's expected type at this // path. Only the substituted form is fed to coercion — literal strings at // non-string paths are left untouched so the decoder can report them with // their original value. - const substituted = substituteEnvLeaf(document, env); if (substituted === document) { return document; } if (ast === null) { return substituted; } - return coerceLeaf(substituted, leafExpectedType(ast)); + return coerceLeaf(substituted, expected); } return document; From 5964d2ec8d04ad3889a7c8c28e10519b3912de67 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 12:28:56 +0100 Subject: [PATCH 74/81] fix(config): scope Go viper decode semantics behind goViperCompat opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5765 wired four Go/viper-parity behaviors unconditionally into the shared packages/config load pipeline, leaking Go-only semantics into next/, packages/stack, and the functions manifest. Gate all four behind a new opt-in LoadProjectConfigOptions.goViperCompat (default false), mirroring the existing tomlOnly/skipEnvLocal precedents: 1. Unconditional [remotes.*] duplicate-project_id/format checks, even without a projectRef (Go: config.go:594-602,996-1001). 2. The deprecated auth.external.{linkedin,slack} WARN on stderr (config.go:1418-1423) — the strip itself stays unconditional. 3. The widened env(...) reference pattern (case-agnostic) vs. the strict SCREAMING_SNAKE_CASE pre-PR pattern. 4. Comma-split coercion of a plain string into a []string field, not just an env()-substituted one (mapstructure.StringToSliceHookFunc). Default-off restores pre-#5765 behavior for every consumer that doesn't opt in. Only the Go-parity legacy shell sets it to true. --- packages/config/src/index.ts | 1 + packages/config/src/io.ts | 76 ++++++--- packages/config/src/io.unit.test.ts | 193 +++++++++++++++++++++-- packages/config/src/lib/env.ts | 34 ++-- packages/config/src/project.ts | 50 ++++-- packages/config/src/project.unit.test.ts | 71 +++++++++ 6 files changed, 367 insertions(+), 58 deletions(-) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index f734e68be7..77af2064ae 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -31,6 +31,7 @@ export { type LoadProjectEnvironmentOptions, type ProjectEnvironment, type ResolvedProjectValue, + type ResolveProjectOptions, loadProjectEnvironment, resolveProjectSubtree, resolveProjectValue, diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index d19c4abd48..2082485c77 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -42,11 +42,14 @@ export interface LoadedProjectConfig { * whose `project_id` equals it) is merged over the base config before decode, * mirroring Go's `config.Load` with `Config.ProjectId` set * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base - * config verbatim (no merge), so existing callers are unaffected — but Go's - * duplicate-`project_id` check across every `[remotes.*]` block - * (`config.go:594-602`) runs unconditionally on every config load, not only - * when a caller ends up selecting a remote, so that check always runs here - * too, even for callers that don't pass a `projectRef`. + * config verbatim (no merge), so existing callers are unaffected. Go's + * duplicate-`project_id`/project-ref-format checks across every + * `[remotes.*]` block (`config.go:594-602,996-1001`) run unconditionally on + * every config load in Go, not only when a caller ends up selecting a + * remote — but here they only run when {@link LoadProjectConfigOptions.goViperCompat} + * is `true`, regardless of whether `projectRef` is set, so non-Go-parity + * callers that never select a remote (and never opt into Go parity) aren't + * broken by an unrelated duplicate/malformed `[remotes.*]` block. */ export interface LoadProjectConfigOptions { readonly projectRef?: string; @@ -71,6 +74,25 @@ export interface LoadProjectConfigOptions { * would never see. */ readonly tomlOnly?: boolean; + /** + * Opt into the Go/viper-parity decode+validation semantics this loader + * otherwise omits, so only the Go-parity legacy shell (and shared modules + * invoked exclusively by it) pays for them. Defaults to `false` = pre-PR-#5765 + * behavior, which `next/`, `packages/stack`, and the functions manifest rely + * on. When `true`, mirrors Go's `config.Load` exactly: + * - runs the unconditional duplicate-`project_id` and project-ref-format + * checks across every `[remotes.*]` block (`config.go:594-602,996-1001`), + * even when no `projectRef` is requested; + * - warns on stderr for deprecated `auth.external.{linkedin,slack}` blocks + * (`config.go:1418-1423`) — the block is stripped from the decoded config + * either way, since the schema ignores excess properties; + * - matches `env(...)` references case-agnostically (`^env\((.*)\)$`) + * rather than the strict SCREAMING_SNAKE_CASE form; + * - splits a comma-separated string into a `[]string`-typed field (Go's + * `mapstructure.StringToSliceHookFunc(",")`, `config.go:775-784`), not + * just an `env()`-substituted one. + */ + readonly goViperCompat?: boolean; } export interface SaveProjectConfigOptions { @@ -148,10 +170,11 @@ function withDbSeedDisabled(document: Record): Record, @@ -178,7 +201,8 @@ const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; * Rejects the first `[remotes.*]` block whose `project_id` is not a valid * project ref, mirroring Go's `Config.Validate` (`config.go:996-1001`) — that * loop runs unconditionally over every remote on every config load, not only - * the one that ends up selected/merged, so this always runs too. + * the one that ends up selected/merged. Here, {@link applyRemoteOverride} only + * invokes this when `goViperCompat` is set. * * Unlike {@link checkDuplicateRemoteProjectIds}/the match below (which read * viper's raw, pre-`LoadEnvHook` values — see {@link applyRemoteOverride}'s @@ -229,13 +253,16 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( rawDocument: Record, interpolatedRemotes: Record | undefined, projectRef: string | undefined, + goViperCompat: boolean, ) { const remotes = rawDocument["remotes"]; if (!isObject(remotes)) { return { document: rawDocument, appliedRemote: undefined as string | undefined }; } - yield* checkDuplicateRemoteProjectIds(remotes); - yield* checkRemoteProjectIdFormat(interpolatedRemotes ?? remotes); + if (goViperCompat) { + yield* checkDuplicateRemoteProjectIds(remotes); + yield* checkRemoteProjectIdFormat(interpolatedRemotes ?? remotes); + } const name = Object.entries(remotes).find(([, remote]) => { const projectId = isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; @@ -560,8 +587,11 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( baseEnv: process.env, search: options?.search, })); + const goViperCompat = options?.goViperCompat ?? false; const interpolateDocument = (document: unknown): unknown => - interpolateEnvReferencesAgainstSchema(document, projectEnv?.values ?? {}, ProjectConfigSchema); + interpolateEnvReferencesAgainstSchema(document, projectEnv?.values ?? {}, ProjectConfigSchema, { + goViperCompat, + }); // Interpolated once here purely to give `applyRemoteOverride`'s FORMAT check // (not its match/merge — see that function's doc comment) the resolved @@ -576,11 +606,10 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( // interpolation) document — Go's `loadFromFile` duplicate-check/selection // loop runs on viper's raw string values, before `LoadEnvHook` ever resolves // `env(...)` (`config.go:594-611`, `decode_hooks.go:13-26`); see - // `applyRemoteOverride`'s doc comment. Runs unconditionally so the - // duplicate-`project_id` check always applies, like Go's own loop, even for - // callers that don't request a `projectRef` — those just never match a - // remote, so the base document still loads verbatim, only now with the - // duplicate check applied too. + // `applyRemoteOverride`'s doc comment. The match/merge itself always runs + // (callers that don't request a `projectRef` just never match a remote, so + // the base document loads verbatim), but the duplicate-`project_id`/format + // checks only run when `goViperCompat` is set — see `applyRemoteOverride`. let documentForDecode: unknown = normalized; let appliedRemote: string | undefined; if (isObject(normalized)) { @@ -588,6 +617,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( normalized, interpolatedRemotes, options?.projectRef, + goViperCompat, ); documentForDecode = resolved.document; appliedRemote = resolved.appliedRemote; @@ -618,10 +648,12 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( // artifact, not the parity-relevant part, same call already made for // `LegacyInvalidPortEnvOverrideError` in the legacy shell. Not reproduced // byte-for-byte; `Console.error` supplies a normal trailing newline instead. - for (const ext of deprecatedProviders) { - yield* Console.error( - `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, - ); + if (goViperCompat) { + for (const ext of deprecatedProviders) { + yield* Console.error( + `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, + ); + } } const config = yield* parseProjectConfig(normalizedForDecode, format, filePath); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 32180b5ab7..006dee8168 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -16,6 +16,7 @@ import { loadProjectConfig, loadProjectConfigFile, saveProjectConfig, + type LoadProjectConfigOptions, } from "./io.ts"; import { loadProjectConfig as loadProjectConfigFromNode } from "./node.ts"; import { projectConfigStoreLayer } from "./project-config.layer.ts"; @@ -832,7 +833,7 @@ additional_redirect_urls = "http://a,http://b" `, ); - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); } finally { await rm(cwd, { recursive: true, force: true }); @@ -854,7 +855,7 @@ additional_redirect_urls = "env(SUPABASE_REDIRECT_URLS)" ); await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_REDIRECT_URLS=http://a,http://b\n"); - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); } finally { await rm(cwd, { recursive: true, force: true }); @@ -875,7 +876,7 @@ additional_redirect_urls = "" `, ); - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); expect(loaded!.config.auth.additional_redirect_urls).toEqual([]); } finally { await rm(cwd, { recursive: true, force: true }); @@ -1004,6 +1005,163 @@ port = "env(SUPABASE_DB_PORT_TEST)" await rm(cwd, { recursive: true, force: true }); } }); + + // Regression coverage for the default-off (`goViperCompat` omitted) path — + // these pin pre-PR-#5765 behavior so `next/`, `packages/stack`, and the + // functions manifest (none of which pass `goViperCompat`) don't inherit the + // Go-parity legacy shell's stricter/wider semantics. + test("loads successfully with a duplicate [remotes.*] project_id when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not split a comma-separated string literal for an array field when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + + const exit = await Effect.runPromiseExit( + loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect((error.value as { _tag: string })._tag).toBe("ProjectConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not warn on a deprecated provider (but still strips it) when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "abc123" + +[auth.external.slack] +enabled = true +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect("slack" in loaded!.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + } finally { + errorSpy.mockRestore(); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not resolve a lowercase-named env() reference when goViperCompat is omitted", async () => { + const previous = process.env.lowercase_ref_default_off_test; + process.env.lowercase_ref_default_off_test = "lowercase-ref-value"; + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "env(lowercase_ref_default_off_test)"\n`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.project_id).toBe("env(lowercase_ref_default_off_test)"); + } finally { + if (previous === undefined) { + delete process.env.lowercase_ref_default_off_test; + } else { + process.env.lowercase_ref_default_off_test = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolves a lowercase-named env() reference when goViperCompat is true", async () => { + const previous = process.env.lowercase_ref_default_on_test; + process.env.lowercase_ref_default_on_test = "lowercase-ref-value"; + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "env(lowercase_ref_default_on_test)"\n`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.project_id).toBe("lowercase-ref-value"); + } finally { + if (previous === undefined) { + delete process.env.lowercase_ref_default_on_test; + } else { + process.env.lowercase_ref_default_on_test = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); }); describe("config io [remotes.*] merge", () => { @@ -1111,7 +1269,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd).pipe( + loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1124,6 +1282,12 @@ project_id = "dupref" } }); + // `goViperCompat` is required even though a `projectRef` is passed: the + // duplicate/format checks in `applyRemoteOverride` are gated solely on + // `goViperCompat`, not on whether a remote is being selected — the remote + // match/merge itself stays unconditional, but pre-PR-#5765 callers that + // pass a `projectRef` without opting into Go parity no longer get these + // checks for free. test("rejects duplicate project_id across remotes with Go's message", async () => { const cwd = await writeTomlProject(`project_id = "baseref" @@ -1135,7 +1299,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "dupref" }).pipe( + loadProjectConfig(cwd, { projectRef: "dupref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1165,7 +1329,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( + loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1193,7 +1357,7 @@ max_rows = 2 `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( + loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1218,7 +1382,7 @@ project_id = "not-a-ref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd).pipe( + loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("InvalidRemoteProjectIdError", (error) => Effect.succeed(error.message)), Effect.provide(BunServices.layer), ), @@ -1290,12 +1454,15 @@ enabled = true // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so // `project_id = "env(project_id)"` resolves against a same-case env var // in the Go CLI. This isn't specific to `project_id`; any string field - // goes through the same pre-decode walk. + // goes through the same pre-decode walk. This case-agnostic matching is + // itself one of the four Go-viper-parity behaviors gated by + // `goViperCompat` — without it, the strict SCREAMING_SNAKE_CASE matcher + // wouldn't match this lowercase name at all. const previous = process.env.project_id; process.env.project_id = "lowercase-ref"; const cwd = await writeTomlProject(`project_id = "env(project_id)"\n`); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); expect(loaded!.config.project_id).toBe("lowercase-ref"); } finally { if (previous === undefined) { @@ -1543,13 +1710,13 @@ describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () errorSpy = undefined; }); - async function loadToml(contents: string) { + async function loadToml(contents: string, options?: LoadProjectConfigOptions) { const cwd = makeTempProject(); const path = await runConfigEffect(configTomlPath(cwd)); await mkdir(join(cwd, "supabase"), { recursive: true }); await writeFile(path, contents); try { - return await runConfigEffect(loadProjectConfigFile(path)); + return await runConfigEffect(loadProjectConfigFile(path, options)); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -1563,6 +1730,7 @@ describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () [auth.external.slack] enabled = true `, + { goViperCompat: true }, ); expect("slack" in loaded.config.auth.external).toBe(false); @@ -1584,6 +1752,7 @@ enabled = true [auth.external.linkedin] enabled = true `, + { goViperCompat: true }, ); expect("linkedin" in loaded.config.auth.external).toBe(false); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 1e099d2e40..23a0c30056 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -8,10 +8,15 @@ import { Schema, SchemaAST } from "effect"; // way it does in the Go CLI. export const ENV_PATTERN = "^env\\((.*)\\)$"; export const ENV_CAPTURE_REGEX = /^env\((.*)\)$/; +// Pre-PR-#5765 strict matcher: SCREAMING_SNAKE_CASE names only. Selected when +// `goViperCompat` is off so non-Go-parity surfaces (next/, packages/stack, the +// functions manifest) keep the narrower matching they had before PR #5765 +// widened env() resolution to Go's case-agnostic `^env\((.*)\)$`. +export const ENV_CAPTURE_REGEX_STRICT = /^env\(([A-Z_][A-Z0-9_]*)\)$/; const envRegex = new RegExp(ENV_PATTERN); -export function isEnvReference(value: string): boolean { - return envRegex.test(value); +export function isEnvReference(value: string, goViperCompat: boolean): boolean { + return (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).test(value); } interface EnvAnnotations extends Schema.Annotations.Documentation { @@ -209,8 +214,12 @@ function coerceLeaf(value: unknown, expected: ExpectedType): unknown { return value; } -function substituteEnvLeaf(value: string, env: Readonly>): string { - const match = ENV_CAPTURE_REGEX.exec(value); +function substituteEnvLeaf( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); if (match === null) { return value; } @@ -247,11 +256,12 @@ function walk( document: unknown, env: Readonly>, ast: SchemaAST.AST | null, + goViperCompat: boolean, ): unknown { if (Array.isArray(document)) { return document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); - return walk(item, env, child); + return walk(item, env, child, goViperCompat); }); } @@ -259,7 +269,7 @@ function walk( const result: Record = {}; for (const [key, value] of Object.entries(document)) { const child = ast === null ? null : descendAst(ast, key); - result[key] = walk(value, env, child); + result[key] = walk(value, env, child, goViperCompat); } return result; } @@ -272,7 +282,7 @@ function walk( return document; } - const substituted = substituteEnvLeaf(document, env); + const substituted = substituteEnvLeaf(document, env, goViperCompat); const expected = ast === null ? "unknown" : leafExpectedType(ast); // Go's `StringToSliceHookFunc(",")` (`apps/cli-go/pkg/config/config.go: @@ -283,9 +293,12 @@ function walk( // coercion below (scoped to substituted values only, since TOML already // decodes literal numbers/booleans to their native type), array coercion // must also apply to literal strings that never went through - // `substituteEnvLeaf`. + // `substituteEnvLeaf`. Gated by `goViperCompat`: when off, the string is + // left unsplit — literal and substituted alike — so an array-typed field + // fed a string fails decode instead of silently coercing, matching + // pre-PR-#5765 behavior. if (expected === "array") { - return coerceLeaf(substituted, expected); + return goViperCompat ? coerceLeaf(substituted, expected) : substituted; } // Substitute env() then coerce based on the schema's expected type at this @@ -325,6 +338,7 @@ export function interpolateEnvReferencesAgainstSchema( document: unknown, env: Readonly>, schema: { readonly ast: SchemaAST.AST }, + options?: { readonly goViperCompat?: boolean }, ): unknown { - return walk(document, env, schema.ast); + return walk(document, env, schema.ast, options?.goViperCompat ?? false); } diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index bbf1ae056c..daa9cc0e08 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -1,10 +1,9 @@ import { Effect, FileSystem, Redacted } from "effect"; import { ProjectConfigSchema } from "./base.ts"; import { ProjectEnvParseError } from "./errors.ts"; -import { ENV_CAPTURE_REGEX, isEnvReference } from "./lib/env.ts"; +import { ENV_CAPTURE_REGEX, ENV_CAPTURE_REGEX_STRICT, isEnvReference } from "./lib/env.ts"; import { findProjectPaths, type ProjectPaths } from "./paths.ts"; -const envReferencePattern = ENV_CAPTURE_REGEX; const dotEnvLinePattern = /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/; @@ -197,6 +196,16 @@ export interface LoadProjectEnvironmentOptions { readonly skipEnvLocal?: boolean; } +export interface ResolveProjectOptions { + /** + * Opt into Go/viper-parity `env()` matching (case-agnostic + * `^env\((.*)\)$`). Defaults to `false`, which uses the pre-PR-#5765 strict + * SCREAMING_SNAKE_CASE matcher (`ENV_CAPTURE_REGEX_STRICT`). Only the + * Go-parity legacy shell sets this to `true`. + */ + readonly goViperCompat?: boolean; +} + export const loadProjectEnvironment = Effect.fnUntraced(function* ( options: LoadProjectEnvironmentOptions, ) { @@ -298,8 +307,12 @@ function isSecretPath(path: ReadonlyArray): boolean { return secretPathPatterns.some((pattern) => matchesPathPattern(pattern, path)); } -function interpolateLeafValue(value: string, env: Readonly>): string { - const match = envReferencePattern.exec(value); +function interpolateLeafValue( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); const envName = match?.[1]; if (envName === undefined) { @@ -331,44 +344,48 @@ function toPathSegments(path: string): ReadonlyArray { return path.split(".").filter((segment) => segment.length > 0); } -function interpolateValue(value: unknown, env: Readonly>): unknown { +function interpolateValue( + value: unknown, + env: Readonly>, + goViperCompat: boolean, +): unknown { if (Array.isArray(value)) { - return value.map((item) => interpolateValue(item, env)); + return value.map((item) => interpolateValue(item, env, goViperCompat)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = interpolateValue(child, env); + result[key] = interpolateValue(child, env, goViperCompat); } return result; } if (typeof value === "string") { - return interpolateLeafValue(value, env); + return interpolateLeafValue(value, env, goViperCompat); } return value; } -function redactValue(value: unknown, path: ReadonlyArray = []): unknown { +function redactValue(value: unknown, path: ReadonlyArray, goViperCompat: boolean): unknown { if (Array.isArray(value)) { - return value.map((item, index) => redactValue(item, [...path, String(index)])); + return value.map((item, index) => redactValue(item, [...path, String(index)], goViperCompat)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = redactValue(child, [...path, key]); + result[key] = redactValue(child, [...path, key], goViperCompat); } return result; } - if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value)) { + if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value, goViperCompat)) { return Redacted.make(value, { label: path.join(".") }); } @@ -379,15 +396,17 @@ function resolveProjectValueAtPath( value: unknown, projectEnv: ProjectEnvironment, path: ReadonlyArray, + goViperCompat: boolean, ): unknown { - const interpolated = interpolateValue(value, projectEnv.values); - return redactValue(interpolated, path); + const interpolated = interpolateValue(value, projectEnv.values, goViperCompat); + return redactValue(interpolated, path, goViperCompat); } export function resolveProjectValue( value: T, projectEnv: ProjectEnvironment, configPath: string, + options?: ResolveProjectOptions, ): Effect.Effect> { return Effect.sync( () => @@ -395,6 +414,7 @@ export function resolveProjectValue( value, projectEnv, toPathSegments(configPath), + options?.goViperCompat ?? false, ) as ResolvedProjectValue, ); } @@ -403,6 +423,7 @@ export function resolveProjectSubtree( value: T, projectEnv: ProjectEnvironment, pathPrefix: string, + options?: ResolveProjectOptions, ): Effect.Effect> { return Effect.sync( () => @@ -410,6 +431,7 @@ export function resolveProjectSubtree( value, projectEnv, toPathSegments(pathPrefix), + options?.goViperCompat ?? false, ) as ResolvedProjectValue, ); } diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index da677db3b1..9f29094bc2 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -509,4 +509,75 @@ account_sid = "AC123" await rm(cwd, { recursive: true, force: true }); } }); + + // Pins the pre-PR-#5765 strict SCREAMING_SNAKE_CASE `env()` matcher as the + // default for `resolveProjectValue`/`resolveProjectSubtree`, since `next/` + // and `packages/stack` call these without ever passing `goViperCompat`. + test("resolveProjectValue does not resolve a lowercase-named env() reference by default", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(lowercase_secret)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (!Redacted.isRedacted(resolved)) { + throw new Error("Expected auth.jwt_secret to be redacted."); + } + expect(Redacted.value(resolved)).toBe("env(lowercase_secret)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolveProjectValue resolves a lowercase-named env() reference when goViperCompat is true", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(lowercase_secret)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret", { + goViperCompat: true, + }), + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (!Redacted.isRedacted(resolved)) { + throw new Error("Expected auth.jwt_secret to be redacted."); + } + expect(Redacted.value(resolved)).toBe("super-secret"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); }); From 4956817d93f8e1d1d7905da00f6dbdff7be95052 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 12:53:14 +0100 Subject: [PATCH 75/81] fix(cli): opt legacy config loading into goViperCompat Go parity semantics The legacy shell is a strict 1:1 Go CLI port, so every loadProjectConfig/ resolveProjectSubtree/resolveProjectValue call site it owns now sets goViperCompat: true, restoring the four Go/viper decode+validation behaviors gated behind that flag in packages/config. Threads goViperCompat through the two shared functions modules (shared/functions/deploy.ts, shared/functions/serve.ts) as a required dependency field so each shell's handler makes an explicit choice: legacy sets it true, next/ sets it false. Adds regression coverage pinning next/'s default-off behavior (functions dev config, next start's config-load path) and legacy's Go-parity-on behavior (status/stop duplicate and malformed [remotes.*] handling, comma-split array decoding, deprecated-provider stderr warning). --- .../commands/config/push/push.handler.ts | 5 +- .../functions/deploy/deploy.handler.ts | 1 + .../commands/functions/new/new.handler.ts | 4 +- .../commands/functions/serve/serve.handler.ts | 1 + .../gen/signing-key/signing-key.handler.ts | 2 +- .../commands/gen/types/types.handler.ts | 4 +- .../commands/secrets/set/set.handler.ts | 3 +- .../commands/seed/buckets/buckets.handler.ts | 4 +- .../legacy/commands/status/status.handler.ts | 1 + .../status/status.integration.test.ts | 96 ++++++++++++++- .../src/legacy/commands/stop/stop.handler.ts | 1 + .../commands/stop/stop.integration.test.ts | 115 ++++++++++++++++++ .../legacy/commands/storage/storage.frame.ts | 4 +- .../functions/deploy/deploy.handler.ts | 1 + .../dev/functions-dev-config.unit.test.ts | 77 +++++++++++- .../commands/start/start.integration.test.ts | 70 +++++++++++ apps/cli/src/shared/functions/deploy.ts | 6 +- apps/cli/src/shared/functions/serve.ts | 22 +++- 18 files changed, 399 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ebd07baabc..60e1869c1e 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -116,7 +116,10 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // Pass `ref` so a matching `[remotes.*]` block is merged over the base config // before decode (Go's `loadFromFile` with `Config.ProjectId` set). A duplicate // `project_id` across remotes surfaces Go's verbatim message. - const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref }).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( Effect.catchTag( "ProjectConfigParseError", (cause) => diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index ece00daaa3..f1f8e6c4c9 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -41,6 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), + goViperCompat: true, yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index b448c0307a..5080e4416b 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -89,7 +89,9 @@ const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) }); const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { - const loaded = yield* loadProjectConfig(workdir).pipe(Effect.orElseSucceed(() => null)); + const loaded = yield* loadProjectConfig(workdir, { goViperCompat: true }).pipe( + Effect.orElseSucceed(() => null), + ); const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; return { diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 0718862e64..86b51f387b 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -33,5 +33,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function debug, networkId, projectIdOverride: cliConfig.projectId, + goViperCompat: true, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 6396c658c6..029209a5ba 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -161,7 +161,7 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd).pipe( + const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacyGenSigningKeyConfigParseError({ diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 6008a3849e..442adefc21 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -266,9 +266,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); } - const loadConfig = () => loadProjectConfig(cliConfig.workdir); + const loadConfig = () => loadProjectConfig(cliConfig.workdir, { goViperCompat: true }); const loadConfigForRef = (projectRef: string) => - loadProjectConfig(cliConfig.workdir, { projectRef }); + loadProjectConfig(cliConfig.workdir, { projectRef, goViperCompat: true }); const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index 422f503070..e03004eaa2 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -53,7 +53,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // literals stay as plain strings, so `Redacted.isRedacted(...)` is the // equivalent guard. const merged = new Map(); - const loaded = yield* loadProjectConfig(runtimeInfo.cwd).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacySecretsConfigParseError({ @@ -72,6 +72,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( loaded.config.edge_runtime, projectEnv, "edge_runtime", + { goViperCompat: true }, ); for (const [name, value] of Object.entries(resolved.secrets ?? {})) { if (Redacted.isRedacted(value)) { diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9ddb8fdb86..fb9a1e70fb 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -155,8 +155,8 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are // merged for --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 71d1fad13e..7e984a0f93 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -188,6 +188,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `config.json` would make `loadProjectConfig` prefer it over // `config.toml`, reporting ports/keys for a config Go never reads. tomlOnly: true, + goViperCompat: true, }).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index ebfd1b7f26..d1988d5569 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -6,7 +6,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { afterEach } from "vitest"; +import { afterEach, vi } from "vitest"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -307,6 +307,100 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // `status` never binds a --project-ref flag, so it must still fail on a + // config-wide duplicate, before ever reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "previewrefaaaaaaaaaa" + +[remotes.b] +project_id = "previewrefaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only a + // remote that ends up selected — so this must fail closed before status + // reaches Docker, even with no --project-ref requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "decodes a comma-separated string into an array field ([]string) for status to proceed", + () => { + // Go's StringToSliceHookFunc (mapstructure) splits a plain string literal + // into a []string for a []string field like additional_redirect_urls — + // this only runs when goViperCompat is on. Pin that status still proceeds + // past config load (and on to a successful Docker inspect/list) rather + // than treating the string literal as a decode error. + const { layer } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nadditional_redirect_urls = "http://a,http://b"\n', + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // Go's `external.validate()` (config.go:1418-1423) disables a bare + // [auth.external.slack] block and warns — mirrored by + // `normalizeDeprecatedExternalProviders` in packages/config's io.ts, gated + // on `goViperCompat` (confirmed already wired in status.handler.ts). The + // WARN goes out via Effect's `Console.error`, not this file's `Output` + // service, so it must be observed with a raw console.error spy — same + // idiom as packages/config/src/io.unit.test.ts's deprecated-provider tests. + const { layer } = setup({ + configContents: 'project_id = "demo"\n[auth.external.slack]\nenabled = true\n', + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('WARN: disabling deprecated "slack" provider'), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before config diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index d32e304484..3e31196de6 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -147,6 +147,7 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject // `config.json` would make `loadProjectConfig` prefer it over // `config.toml`, potentially stopping containers for the wrong project. tomlOnly: true, + goViperCompat: true, }).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 9229dea1fc..3ac32c623c 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -5,6 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { vi } from "vitest"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -670,6 +671,120 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's Config.Validate builds the duplicate map across all [remotes.*] + // blocks unconditionally (config.go:503-518), so this must fail before + // stop ever selects a remote or touches Docker — not just when a + // matching --project-ref is requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "aaaaaaaaaaaaaaaaaaaa" + +[remotes.b] +project_id = "aaaaaaaaaaaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load, so an invalid + // format must fail closed before stop reaches Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "decodes a comma-separated string into an array field ([]string) for stop to proceed", + () => { + // Go's `newDecodeHook` wires `mapstructure.StringToSliceHookFunc(",")` + // unconditionally, so a plain string value for a `[]string` field like + // `additional_redirect_urls` decodes fine and must not block stop from + // reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits + // this WARN via `Console.error` only when `goViperCompat` is set — verify + // legacy stop keeps that Go-parity behavior wired on. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth.external.slack] +enabled = true +`, + ); + const { layer } = setup({ skipConfig: true, route: defaultRoute() }); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(warnings.some((m) => m.includes('WARN: disabling deprecated "slack" provider'))).toBe( + true, + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + it.live( "fails and never spawns docker when config.toml has an unsupported db.major_version", () => { diff --git a/apps/cli/src/legacy/commands/storage/storage.frame.ts b/apps/cli/src/legacy/commands/storage/storage.frame.ts index f4d5fd15f9..dfad46cac2 100644 --- a/apps/cli/src/legacy/commands/storage/storage.frame.ts +++ b/apps/cli/src/legacy/commands/storage/storage.frame.ts @@ -50,8 +50,8 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( workdir: string, projectRef: string, ) { - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts index 1b61948324..acd17b7a1b 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts @@ -34,6 +34,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( projectRoot: projectHome.projectRoot, supabaseDir: projectHome.supabaseDir, dashboardUrl: cliConfig.dashboardUrl, + goViperCompat: false, yes: flags.yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index b31bdd35b9..983a7a6a5e 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { @@ -147,4 +147,79 @@ literal = "literal-secret" Effect.provide(projectLayer(cwd)), ); }); + + it.live("does not resolve a lowercase-named env() reference in edge runtime secrets", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "lowercase_env_var=should-not-resolve\n"), + ); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "test" + +[edge_runtime] +policy = "oneshot" +inspector_port = 8123 + +[edge_runtime.secrets] +lowercase_secret = "env(lowercase_env_var)" +`, + ), + ); + + const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); + + expect(result.config).toEqual({ + enabled: true, + inspectorPort: 8123, + policy: "oneshot", + env: { + LOWERCASE_SECRET: "env(lowercase_env_var)", + }, + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); + }); + + it.live("does not split a comma-separated string literal for an array field", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), + ); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "test" + +[auth] +additional_redirect_urls = "http://a,http://b" + +[edge_runtime] +policy = "oneshot" +inspector_port = 8123 + +[edge_runtime.secrets] +api_key = "env(EDGE_API_KEY)" +literal = "literal-secret" +`, + ), + ); + + const exit = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); + }); }); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 7179a25000..be7b06c555 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,7 +1,12 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Exit, Fiber, Layer } from "effect"; import type { StackServiceStatus } from "@supabase/stack"; import { DEFAULT_VERSIONS, stackMetadata, type StackInfo } from "@supabase/stack/effect"; +import { loadProjectConfig } from "@supabase/config"; import { start } from "./start.handler.ts"; import { StartVersionState } from "./start.command.ts"; import { startForegroundWithStopSignal } from "./flows/foreground.flow.ts"; @@ -532,3 +537,68 @@ describe("start", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("next start config-load path (goViperCompat default-off regression)", () => { + // start.command.ts's `Command.provide` factory calls + // `loadProjectConfig(projectHome.projectRoot)` with no options (line ~183) — + // deeply inside `Layer.unwrap`/`StateManager`/daemon-layer construction that + // this file's `start()`-from-handler harness (StartVersionState provided + // directly) never reaches and can't be extended to reach without + // reproducing that machinery. This test instead exercises the exact + // zero-options `loadProjectConfig` call directly against a real temp + // project, pinning that `next start` still loads successfully when a + // config.toml has a duplicate or malformed `[remotes.*]` block — the + // regression that motivated gating these Go-viper checks behind + // `goViperCompat` (packages/config/src/io.unit.test.ts has the + // authoritative, broader coverage of this default-off behavior). + it("loads successfully despite a duplicate [remotes.*] project_id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-next-start-config-")); + try { + await mkdir(join(tempDir, "supabase"), { recursive: true }); + await writeFile( + join(tempDir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`, + ); + + const result = await Effect.runPromise( + loadProjectConfig(tempDir).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result).not.toBeNull(); + expect(result?.config.project_id).toBe("baseref"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("loads successfully despite a malformed [remotes.*] project_id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-next-start-config-")); + try { + await mkdir(join(tempDir, "supabase"), { recursive: true }); + await writeFile( + join(tempDir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`, + ); + + const result = await Effect.runPromise( + loadProjectConfig(tempDir).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result).not.toBeNull(); + expect(result?.config.project_id).toBe("baseref"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 46bab06023..90a933ba76 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -54,6 +54,7 @@ interface DeployFunctionsDependencies { readonly projectRoot: string; readonly supabaseDir: string; readonly dashboardUrl: string; + readonly goViperCompat: boolean; readonly yes?: boolean; readonly rawArgs: ReadonlyArray; readonly edgeRuntimeVersion: string; @@ -2155,7 +2156,10 @@ export function deployFunctions( // `@supabase/config` merges the matching `[remotes.*]` block over the base // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved // config already reflects any remote function/edge_runtime overrides. - const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { projectRef }); + const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { + projectRef, + goViperCompat: dependencies.goViperCompat, + }); const deployConfig = loadedConfig?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( deployConfig?.edge_runtime.deno_version, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index e11a1c4736..710afdd972 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -127,6 +127,7 @@ export interface FunctionsServeDependencies { readonly debug: boolean; readonly networkId: Option.Option; readonly projectIdOverride: Option.Option; + readonly goViperCompat: boolean; } interface PlainServeAuthConfig { @@ -616,6 +617,7 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( const resolveServeConfig = Effect.fnUntraced(function* ( projectRoot: string, projectIdOverride: Option.Option, + goViperCompat: boolean, ) { const projectEnv = yield* loadServeProjectEnvironment(projectRoot); const projectRef = Option.match(projectIdOverride, { @@ -632,28 +634,35 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const loadedConfig = yield* loadProjectConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { projectEnv }), + goViperCompat, }); const baseConfig = loadedConfig?.config ?? defaultProjectConfig; const auth = projectEnv === null ? toPlainAuthConfig(baseConfig.auth) - : toPlainAuthConfig(yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth")); + : toPlainAuthConfig( + yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth", { goViperCompat }), + ); const edgeRuntime = projectEnv === null ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) : toPlainEdgeRuntimeConfig( - yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime"), + yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime", { + goViperCompat, + }), ); const apiPort = projectEnv === null ? baseConfig.api.port - : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api")).port; + : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api", { goViperCompat })).port; const configDeclaredFunctions = projectEnv === null ? toPlainFunctionRecord(baseConfig.functions) : toPlainFunctionRecord( - yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions"), + yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions", { + goViperCompat, + }), ); const configForManifest: ProjectConfig = { ...baseConfig, @@ -667,7 +676,9 @@ const resolveServeConfig = Effect.fnUntraced(function* ( projectEnv === null ? (baseConfig.project_id ?? "") : (reveal( - yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id"), + yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id", { + goViperCompat, + }), ) ?? ""); const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); const fallbackProjectId = basename(resolve(projectRoot)); @@ -1296,6 +1307,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { const resolved = yield* resolveServeConfig( input.dependencies.projectRoot, input.dependencies.projectIdOverride, + input.dependencies.goViperCompat, ); const projectId = resolved.projectId; const containerId = localDockerId("edge_runtime", projectId); From 4086791e283cf8314a6afa04321d6d6778235568 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 13:41:20 +0100 Subject: [PATCH 76/81] cleanup(cli): add legacy-config-validate as the single home for Config.Validate parity Relocates the 5 shared Go-parity regex patterns (bucket name, function slug, hook secret, clerk domain, project ref) and legacyParseGoBool out of legacy-db-config.toml-read.ts into a new legacy-config-validate.ts, verbatim and with no behavior change. legacy-local-config-values.ts's existing import of the same symbols is repointed at the new module. This is a purely mechanical move: it establishes the single home the Config.Validate validator itself will land in during a follow-up commit, per apps/cli/AGENTS.md's "Hoist Before You Duplicate" policy. --- .../legacy/shared/legacy-config-validate.ts | 125 ++++++++++++++++++ .../legacy-config-validate.unit.test.ts | 92 +++++++++++++ .../shared/legacy-db-config.toml-read.ts | 59 ++------- .../shared/legacy-local-config-values.ts | 2 +- 4 files changed, 226 insertions(+), 52 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-config-validate.ts create mode 100644 apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts new file mode 100644 index 0000000000..5a5d5a24f3 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -0,0 +1,125 @@ +/** + * Single home for Go's `Config.Validate` parity (`apps/cli-go/pkg/config/config.go:989-1192`), + * consolidating the two independent TypeScript ports of that logic: + * + * - **D** = `legacy-db-config.toml-read.ts` — raw smol-toml document + `EnvLookup`, + * Effect-based, fails with `LegacyDbConfigLoadError`. Feeds ~15 db/migration commands via + * `legacy-db-config.layer.ts`. + * - **L** = `legacy-local-config-values.ts` — decoded `@supabase/config` `ProjectConfig`, + * synchronous `node:fs`, throws plain `Error`. Feeds `status/status.values.ts` and + * `stop/stop.handler.ts`. + * + * **This file is the SINGLE home for `Config.Validate` parity going forward. + * Per-command reimplementations of any branch below are forbidden** — hoist here instead, + * per `apps/cli/AGENTS.md`'s "Hoist Before You Duplicate" policy. + * + * ## Status of this commit + * + * This commit is a MECHANICAL RELOCATION ONLY: it moves the five shared regex patterns and + * `legacyParseGoBool` (+ its private `GO_BOOL_TRUE`/`GO_BOOL_FALSE` acceptance sets) out of D, + * verbatim, with no behavior change. **The `legacyValidateResolvedConfig` entry point itself — + * i.e. the actual porting of the branches tabulated below into one pure, ordered validator — + * lands in a follow-up commit**, not this one. This header documents the FULL eventual scope of + * the module up front so the ownership contract is visible starting from commit 1. + * + * ## Full eventual scope: every `Config.Validate` branch this module will own + * + * In Go's exact `Validate()` order (`config.go:989-1192`), first-failure-wins: + * + * | Go line(s) | Check | + * |--------------------------------|-------| + * | 990-991 | `project_id` required | + * | 1006-1027 | `api.port` / `api.tls.{cert,key}_path` presence (the actual file reads stay per-caller I/O) | + * | 1031-1062 | `db.port`, `db.major_version` (0 / 12 / 13-17 switch) | + * | 1064-1068, pattern @ 1549-1554 | `storage.buckets.*` names vs `LEGACY_BUCKET_NAME_PATTERN` | + * | 1070-1079 | `studio.port` / `studio.api_url` (L-only — D has no studio section) | + * | 1081-1085 | `local_smtp.port` (L-only) | + * | 1087-1153 | `auth.*` sub-sequence, in order: site_url (1088-1090); captcha enum + presence (1099-1109, enum itself decode-time per `auth.go:58-71`); signing_keys read (1110-1116, caller-side I/O); passkey/webauthn (1117-1134); hooks (1136-1138, checks @ 1453-1521, vs `LEGACY_HOOK_SECRET_PATTERN`); mfa (1139-1141, checks @ 1523-1534); email template/notification content-vs-content_path (1293-1323, caller-side I/O) + smtp (1325-1344); third_party (1151-1153, checks @ 1635-1683, vs `LEGACY_CLERK_DOMAIN_PATTERN`) | + * | 1159-1163, pattern @ 1539-1544 | `functions.*` slugs vs `LEGACY_FUNCTION_SLUG_PATTERN` | + * | 1164-1173 | `edge_runtime.deno_version` (0 / 1 / 2 switch) | + * | (decode-time enum) | `analytics.backend` must be `postgres`/`bigquery` | + * | 1175-1187 | `analytics.gcp_*` fields, gated on `backend === "bigquery"` | + * | 1846-1854 | `experimental.webhooks` / `experimental.pgdelta.format_options` | + * + * ## Explicitly OUT of scope forever (D-only, NEVER part of this module) + * + * - `remotes[*].project_id` pattern (`config.go:997-1001`, vs `LEGACY_PROJECT_REF_PATTERN`) — + * D's own remote-merge-phase check (`findInvalidRemoteProjectId`), never shared with L. + * - `auth.sms` (`config.go:1145-1147`/`1348-1417`) — stays 100% inline in D. + * - `auth.external` (`config.go:1148-1150`/`1419-1451`) — stays 100% inline in D. + * - `auth.jwt_secret` length check (`apikeys.go:43-73`, `generateAPIKeys`) — each caller's own + * key-generation flow (D and L both already implement this separately), not part of + * `Config.Validate`'s pure-check set. + * + * `legacyExpandEnv` also stays in D (env-substitution machinery, not a validation leaf). + * + * ## Known ordering tradeoff (D only, accepted — do not "fix") + * + * Go's real auth-block order is site_url → captcha → signing_keys[IO] → passkey → hooks → mfa → + * email[IO]+smtp → **sms → external** → third_party. Since sms/external are D-only and never + * part of this module, but third_party IS shared, D cannot call the eventual + * `legacyValidateResolvedConfig` in a way that preserves relative ordering across the + * sms/external ↔ third_party boundary without complex multi-phase calls. Decision: D calls + * `legacyValidateResolvedConfig` ONCE with the full input (including third_party), positioned + * after D's own signing-keys and email-template I/O reads; D's inline sms/external checks then + * run AFTER that single call succeeds. This means: if third_party is broken, its error surfaces + * (matching Go); D's sms/external checks never run in that case. The only real behavior change + * from today: for the (untested, unrealistic) case where sms/external AND third_party are BOTH + * simultaneously broken in the same config.toml, Go/today's-D would report the sms/external + * error first, but the refactored D reports third_party's error first, since third_party is + * checked inside the single earlier shared call. This is an accepted, narrow, documented parity + * gap — the same category of accepted imperfection already sanctioned for the three I/O checks + * above (their cross-section ordering vs. simultaneous unrelated pure-section failures is + * similarly not byte-guaranteed). + */ + +// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 lowercase +// ASCII letters. Exported from this module (was private in D before this relocation) as the +// canonical home; D's `findInvalidRemoteProjectId` is today the only consumer — the +// `remotes[*].project_id` check itself stays D-only forever, see the module header above. +export const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). +// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key +// during config load (`config.go:898-903`), aborting before any db command when a +// name does not match. The source string is reused verbatim in the error message via +// `.source` so it byte-matches Go's `bucketNamePattern.String()`. Used by both D +// (`legacy-db-config.toml-read.ts`) and L (`legacy-local-config-values.ts`). +export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; + +// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` +// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load +// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused +// in the message so it byte-matches Go's `funcSlugPattern.String()`. Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above). +export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; + +// Go's `hookSecretPattern` (`apps/cli-go/pkg/config/config.go:1436`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above). +export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; + +// Go's `clerkDomainPattern` (`apps/cli-go/pkg/config/config.go:1553`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above). +export const LEGACY_CLERK_DOMAIN_PATTERN = + /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; + +// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under +// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty +// string is `false`, and any other value is a parse error. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); + +/** + * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly + * typed decode). Returns the bool, or `undefined` for a malformed value (which Go + * surfaces as a `failed to parse config` error). + * + * Used by both D (`legacy-db-config.toml-read.ts`'s `resolveBool`/`resolveBoolOrFail`) and + * L (`legacy-local-config-values.ts`'s `legacyEnvOverrideBool`) for their `SUPABASE_*` + * bool-flavored env overrides and TOML bool decoding. + */ +export function legacyParseGoBool(value: string): boolean | undefined { + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; + return undefined; +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts new file mode 100644 index 0000000000..57d02a53fe --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_BUCKET_NAME_PATTERN, + LEGACY_CLERK_DOMAIN_PATTERN, + LEGACY_FUNCTION_SLUG_PATTERN, + LEGACY_HOOK_SECRET_PATTERN, + LEGACY_PROJECT_REF_PATTERN, + legacyParseGoBool, +} from "./legacy-config-validate.ts"; + +// Starter suite for the symbols relocated from `legacy-db-config.toml-read.ts` in this +// commit. The bulk of `Config.Validate` behavioral coverage (the eventual +// `legacyValidateResolvedConfig` entry point) moves here in a later commit — see the +// module header in `legacy-config-validate.ts`. + +describe("legacyParseGoBool", () => { + it("accepts Go's strconv.ParseBool true forms", () => { + for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { + expect(legacyParseGoBool(value)).toBe(true); + } + }); + + it("accepts Go's strconv.ParseBool false forms, including the empty string", () => { + for (const value of ["0", "f", "F", "FALSE", "false", "False", ""]) { + expect(legacyParseGoBool(value)).toBe(false); + } + }); + + it("returns undefined for a value outside Go's strconv.ParseBool acceptance set", () => { + expect(legacyParseGoBool("yes")).toBeUndefined(); + expect(legacyParseGoBool("2")).toBeUndefined(); + }); +}); + +describe("LEGACY_PROJECT_REF_PATTERN", () => { + it("matches a valid 20-character lowercase project ref", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("abcdefghijklmnopqrst")).toBe(true); + }); + + it("rejects refs of the wrong length or case", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("short")).toBe(false); + expect(LEGACY_PROJECT_REF_PATTERN.test("ABCDEFGHIJKLMNOPQRST")).toBe(false); + }); +}); + +describe("LEGACY_BUCKET_NAME_PATTERN", () => { + it("matches Go-legal bucket name characters", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("my-bucket.1")).toBe(true); + }); + + it("rejects characters outside Go's bucketNamePattern", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad#name")).toBe(false); + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad/name")).toBe(false); + }); +}); + +describe("LEGACY_FUNCTION_SLUG_PATTERN", () => { + it("matches a valid function slug (letters, digits, _ and -)", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("my-function")).toBe(true); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("function_1")).toBe(true); + }); + + it("rejects a slug that doesn't start with a letter", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("123")).toBe(false); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("1bad")).toBe(false); + }); +}); + +describe("LEGACY_HOOK_SECRET_PATTERN", () => { + it("matches a valid v1,whsec_ secret", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test(`v1,whsec_${"a".repeat(32)}`)).toBe(true); + }); + + it("rejects a secret that doesn't match Go's hookSecretPattern", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test("not-a-valid-secret")).toBe(false); + }); +}); + +describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { + it("matches a valid clerk.example.com domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("clerk.example.com")).toBe(true); + }); + + it("matches a valid .clerk.accounts.dev domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("example.clerk.accounts.dev")).toBe(true); + }); + + it("rejects a domain that doesn't match Go's clerkDomainPattern", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index d7eaf6f8e7..1fb9c9ab96 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,5 +1,13 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; +import { + LEGACY_BUCKET_NAME_PATTERN, + LEGACY_CLERK_DOMAIN_PATTERN, + LEGACY_FUNCTION_SLUG_PATTERN, + LEGACY_HOOK_SECRET_PATTERN, + LEGACY_PROJECT_REF_PATTERN, + legacyParseGoBool, +} from "./legacy-config-validate.ts"; import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { @@ -336,27 +344,6 @@ function findDuplicateRemoteProjectId( return undefined; } -// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 -// lowercase ASCII letters. -const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). -// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key -// during config load (`config.go:898-903`), aborting before any db command when a -// name does not match. The source string is reused verbatim in the error message via -// `.source` so it byte-matches Go's `bucketNamePattern.String()`. Exported: also used -// by `legacy-local-config-values.ts`'s `status`/`stop` resolver, which needs the -// identical unconditional check Go's `Config.Validate` runs — hoisted rather than -// duplicated per this package's "hoist before you duplicate" rule. -export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` -// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load -// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused -// in the message so it byte-matches Go's `funcSlugPattern.String()`. Exported for the -// same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above. -export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; - /** * Go's `config.Validate` rejects any `[remotes.]` whose `project_id` is not a * valid project ref (`config.go:832-836`), on every config load — so a malformed or @@ -613,27 +600,6 @@ function legacyIsValidJson(value: string): boolean { } } -// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under -// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty -// string is `false`, and any other value is a parse error. -const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); - -/** - * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly - * typed decode). Returns the bool, or `undefined` for a malformed value (which Go - * surfaces as a `failed to parse config` error). - * - * Exported for reuse by other `legacy/shared/` bool-flavored `SUPABASE_*` env - * overrides (e.g. `legacy-local-config-values.ts`'s `api.tls.enabled`/ - * `auth.enabled`) that need the same `strconv.ParseBool` acceptance set. - */ -export function legacyParseGoBool(value: string): boolean | undefined { - if (GO_BOOL_TRUE.has(value)) return true; - if (GO_BOOL_FALSE.has(value)) return false; - return undefined; -} - /** * Resolve a `[section] enabled` style bool. Go decodes a TOML bool natively and a * string (incl. an `env(VAR)` reference) via `strconv.ParseBool` — so `"1"`/`"t"`/etc. @@ -773,15 +739,6 @@ const legacyAssertDecryptableSecrets = ( // Go merges the template default before Validate (`templates/config.toml`), so an absent // `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; -// Go's `hookSecretPattern` (`pkg/config/config.go:1436`). Exported: also used by -// `legacy-local-config-values.ts`'s `status`/`stop` resolver — hoisted rather than -// duplicated per this package's "hoist before you duplicate" rule. -export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; -// Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). Exported: also used by -// `legacy-local-config-values.ts`'s `status`/`stop` resolver — hoisted rather than -// duplicated per this package's "hoist before you duplicate" rule. -export const LEGACY_CLERK_DOMAIN_PATTERN = - /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; /** * Ports the FATAL validations Go runs inside `if c.Auth.Enabled { … }` during diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 479b5ce497..1f6ea609bb 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -12,7 +12,7 @@ import { LEGACY_FUNCTION_SLUG_PATTERN, LEGACY_HOOK_SECRET_PATTERN, legacyParseGoBool, -} from "./legacy-db-config.toml-read.ts"; +} from "./legacy-config-validate.ts"; import { legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, From 49fc64ae1092d0419dd7d8f953beae157be268fa Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 14:14:14 +0100 Subject: [PATCH 77/81] cleanup(cli): route status/stop config validation through legacy-config-validate Implements legacyValidateResolvedConfig in legacy-config-validate.ts, porting every Config.Validate branch L (legacy-local-config-values.ts) previously duplicated inline (storage buckets, studio, local_smtp, auth site_url/captcha/ passkey/hooks/mfa/smtp/third_party, function slugs, deno_version, analytics, experimental) into the single shared home, plus the 3 pure I/O helpers for signing keys, api.tls cert/key, and email template/notification content path resolution. legacyResolveLocalConfigValues now accumulates inputs across its existing value-derivation order and calls the shared validator once at the end; the 3 file reads stay inline at their original position. D (legacy-db-config.toml-read.ts) is unchanged and still wired up in a follow-up commit. --- .../legacy/shared/legacy-config-validate.ts | 685 ++++++++++- .../shared/legacy-local-config-values.ts | 1043 ++++++----------- 2 files changed, 1046 insertions(+), 682 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index 5a5d5a24f3..79f7a0acad 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -1,3 +1,7 @@ +import { isAbsolute, join } from "node:path"; + +import { legacyGoUrlParse } from "./legacy-storage-url.ts"; + /** * Single home for Go's `Config.Validate` parity (`apps/cli-go/pkg/config/config.go:989-1192`), * consolidating the two independent TypeScript ports of that logic: @@ -15,14 +19,14 @@ * * ## Status of this commit * - * This commit is a MECHANICAL RELOCATION ONLY: it moves the five shared regex patterns and - * `legacyParseGoBool` (+ its private `GO_BOOL_TRUE`/`GO_BOOL_FALSE` acceptance sets) out of D, - * verbatim, with no behavior change. **The `legacyValidateResolvedConfig` entry point itself — - * i.e. the actual porting of the branches tabulated below into one pure, ordered validator — - * lands in a follow-up commit**, not this one. This header documents the FULL eventual scope of - * the module up front so the ownership contract is visible starting from commit 1. + * {@link legacyValidateResolvedConfig} is now IMPLEMENTED and wired into L + * (`legacy-local-config-values.ts`'s `legacyResolveLocalConfigValues`) — see that file for how + * it builds a {@link LegacyConfigValidationInput} from a decoded `ProjectConfig` + raw + * `document`. D (`legacy-db-config.toml-read.ts`) still runs its own inline copy of this same + * logic; wiring D through this module (and fixing D's `db.major_version === 0` divergence in + * the process) lands in a follow-up commit. * - * ## Full eventual scope: every `Config.Validate` branch this module will own + * ## Full eventual scope: every `Config.Validate` branch this module owns * * In Go's exact `Validate()` order (`config.go:989-1192`), first-failure-wins: * @@ -53,24 +57,35 @@ * * `legacyExpandEnv` also stays in D (env-substitution machinery, not a validation leaf). * - * ## Known ordering tradeoff (D only, accepted — do not "fix") + * ## Known ordering tradeoff (accepted — do not "fix") * * Go's real auth-block order is site_url → captcha → signing_keys[IO] → passkey → hooks → mfa → * email[IO]+smtp → **sms → external** → third_party. Since sms/external are D-only and never - * part of this module, but third_party IS shared, D cannot call the eventual - * `legacyValidateResolvedConfig` in a way that preserves relative ordering across the - * sms/external ↔ third_party boundary without complex multi-phase calls. Decision: D calls - * `legacyValidateResolvedConfig` ONCE with the full input (including third_party), positioned - * after D's own signing-keys and email-template I/O reads; D's inline sms/external checks then - * run AFTER that single call succeeds. This means: if third_party is broken, its error surfaces - * (matching Go); D's sms/external checks never run in that case. The only real behavior change - * from today: for the (untested, unrealistic) case where sms/external AND third_party are BOTH - * simultaneously broken in the same config.toml, Go/today's-D would report the sms/external - * error first, but the refactored D reports third_party's error first, since third_party is - * checked inside the single earlier shared call. This is an accepted, narrow, documented parity - * gap — the same category of accepted imperfection already sanctioned for the three I/O checks - * above (their cross-section ordering vs. simultaneous unrelated pure-section failures is - * similarly not byte-guaranteed). + * part of this module, but third_party IS shared, D cannot call + * {@link legacyValidateResolvedConfig} in a way that preserves relative ordering across the + * sms/external ↔ third_party boundary without complex multi-phase calls. Decision (applies once + * D is wired up in a follow-up commit): D calls {@link legacyValidateResolvedConfig} ONCE with + * the full input (including third_party), positioned after D's own signing-keys and + * email-template I/O reads; D's inline sms/external checks then run AFTER that single call + * succeeds. This means: if third_party is broken, its error surfaces (matching Go); D's + * sms/external checks never run in that case. The only real behavior change from today: for the + * (untested, unrealistic) case where sms/external AND third_party are BOTH simultaneously + * broken in the same config.toml, Go/today's-D would report the sms/external error first, but + * the refactored D reports third_party's error first, since third_party is checked inside the + * single earlier shared call. This is an accepted, narrow, documented parity gap. + * + * The same category of tradeoff now also applies to L: `legacyResolveLocalConfigValues` calls + * {@link legacyValidateResolvedConfig} exactly ONCE, at the very end, after every value this + * module needs has been derived — including L's 3 I/O reads (signing keys, `api.tls` cert/key, + * email template/notification content), which stay at their original textual position (per-caller + * I/O, same as D's). Every pure check this module owns is therefore checked in Go's exact + * relative order against every OTHER pure check, but an I/O read that in L's source sits + * between two pure sections (e.g. the signing-keys read sits between the captcha check and the + * passkey/hooks/mfa/email/smtp/third_party checks) now effectively runs BEFORE any of those + * later pure checks, rather than interleaved at its original relative position — the same + * narrow, accepted, documented tradeoff, not something to "fix" by splitting this function into + * multiple calls. Every existing test constructs exactly one validation failure at a time, so + * this has zero effect on any real test. */ // Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 lowercase @@ -84,22 +99,26 @@ export const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; // during config load (`config.go:898-903`), aborting before any db command when a // name does not match. The source string is reused verbatim in the error message via // `.source` so it byte-matches Go's `bucketNamePattern.String()`. Used by both D -// (`legacy-db-config.toml-read.ts`) and L (`legacy-local-config-values.ts`). +// (`legacy-db-config.toml-read.ts`) and L (`legacy-local-config-values.ts`), and internally by +// {@link legacyValidateResolvedConfig}'s storage-bucket-names step (`config.go:1064-1068`). export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; // Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` // runs `ValidateFunctionSlug` over every `[functions.*]` key during config load // (`config.go:993-998`), rejecting the config before any db command. `.source` is reused // in the message so it byte-matches Go's `funcSlugPattern.String()`. Used by both D and L -// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above). +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s function-slugs step (`config.go:1159-1163`). export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; // Go's `hookSecretPattern` (`apps/cli-go/pkg/config/config.go:1436`). Used by both D and L -// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above). +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s hooks step (`config.go:1453-1521`). export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; // Go's `clerkDomainPattern` (`apps/cli-go/pkg/config/config.go:1553`). Used by both D and L -// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above). +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s third_party step (`config.go:1635-1683`). export const LEGACY_CLERK_DOMAIN_PATTERN = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; @@ -123,3 +142,617 @@ export function legacyParseGoBool(value: string): boolean | undefined { if (GO_BOOL_FALSE.has(value)) return false; return undefined; } + +/** + * Thrown by {@link legacyValidateResolvedConfig}. Deliberately does NOT override `.name` in a + * constructor — it stays the inherited `"Error"` — so `.toString()`/`.name`/`instanceof Error` + * checks are indistinguishable from a plain `new Error(message)`. Both D and L's existing + * callers/tests observe only `.message` (via `cause instanceof Error ? cause.message : ...` or + * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class + * is a byte-identical, purely internal refactor. + */ +export class LegacyConfigValidateError extends Error {} + +/** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ +export interface LegacyApiInput { + readonly enabled: boolean; + readonly port: number; + readonly tls: { + readonly enabled: boolean; + readonly certPath: string | undefined; + readonly keyPath: string | undefined; + }; +} + +/** `[db]`, post-env-override. Required — Go validates `db.port`/`db.major_version` unconditionally. */ +export interface LegacyDbInput { + readonly port: number; + readonly majorVersion: number; +} + +/** `[studio]`, post-env-override. L-only — D has no studio section. */ +export interface LegacyStudioInput { + readonly enabled: boolean; + readonly port: number; + readonly apiUrl: string; +} + +/** `[local_smtp]` (Go's `Inbucket`), post-env-override. L-only. */ +export interface LegacyLocalSmtpInput { + readonly enabled: boolean; + readonly port: number; +} + +/** `[auth.captcha]`. `provider` is deliberately `string | undefined`, not a narrow union — see + * divergence #2 in the module's port plan: D passes a raw, untyped TOML string (the enum check + * is live for D); L's `@supabase/config`-decoded value is already schema-narrowed to + * `"hcaptcha" | "turnstile" | undefined` before this function ever sees it, making the branch + * dead-but-harmless for L specifically, while still needing the same widened type to keep this + * field honest and reusable across both callers. + */ +export interface LegacyCaptchaInput { + readonly enabled: boolean; + readonly provider: string | undefined; + readonly secret: string | undefined; +} + +/** `[auth.passkey]` + `[auth.webauthn]`. Present iff `passkey.enabled === true`. */ +export interface LegacyPasskeyInput { + readonly webauthnPresent: boolean; + readonly rpId: string | undefined; + readonly rpOrigins: ReadonlyArray | undefined; +} + +/** One enabled `[auth.hook.]` entry. Caller pre-filters to enabled-only and pre-orders + * per Go's fixed hook-type iteration order (`config.go:1453-1485`). */ +export interface LegacyHookInput { + readonly type: + | "mfa_verification_attempt" + | "password_verification_attempt" + | "custom_access_token" + | "send_sms" + | "send_email" + | "before_user_created"; + /** Post-env-expand; `""` = absent. */ + readonly uri: string; + /** Post-env-expand; `""` = absent. */ + readonly secrets: string; +} + +/** One `[auth.mfa.]` entry. Caller pre-orders totp, phone, web_authn. */ +export interface LegacyMfaFactorInput { + readonly label: "totp" | "phone" | "web_authn"; + readonly enrollEnabled: boolean; + readonly verifyEnabled: boolean; +} + +/** `[auth.email.smtp]`. Present iff the raw TOML table itself is present (Go's presence-based + * `enabled` default, `config.go:743-748` — NOT the decoded, always-defaulted value). */ +export interface LegacySmtpInput { + readonly enabled: boolean; + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; +} + +/** One enabled `[auth.third_party.]` entry. Caller pre-filters to enabled-only and + * pre-orders per Go's fixed provider order (firebase, auth0, cognito, clerk, workos). */ +export interface LegacyThirdPartyInput { + readonly provider: "firebase" | "auth0" | "cognito" | "clerk" | "workos"; + /** `project_id` / `tenant` / `user_pool_id` / `domain` / `issuer_url`, per provider. */ + readonly requiredField: string; + /** cognito's second required field only. */ + readonly cognitoUserPoolRegion?: string; +} + +/** `[auth]`. Present in {@link LegacyConfigValidationInput} iff auth is enabled — matches Go's + * `if c.Auth.Enabled` gate wrapping this entire sub-sequence (`config.go:1087-1153`). */ +export interface LegacyAuthInput { + readonly siteUrl: string; + readonly captcha?: LegacyCaptchaInput; + readonly passkey?: LegacyPasskeyInput; + readonly hooks: ReadonlyArray; + readonly mfa: ReadonlyArray; + readonly smtp?: LegacySmtpInput; + readonly thirdParty: ReadonlyArray; +} + +/** `[analytics]`, post-env-override. Unconditional entry — internally gated on `enabled` + + * `backend === "bigquery"`. `backend` is `string | undefined` for the same dead-but-harmless-for-L + * reason as {@link LegacyCaptchaInput.provider} — see divergence #2. */ +export interface LegacyAnalyticsInput { + readonly enabled: boolean; + readonly backend: string | undefined; + readonly gcpProjectId: string; + readonly gcpProjectNumber: string; + readonly gcpJwtPath: string; +} + +/** `[experimental]`. Unconditional entry — internally gated. `webhooksPresent`/`webhooksEnabled` + * hinge on TOML-section PRESENCE (not the decoded, always-defaulted `enabled` value) — see + * `config.go:1846-1854` and the callers' own doc comments for why. */ +export interface LegacyExperimentalInput { + readonly webhooksPresent?: boolean; + readonly webhooksEnabled?: boolean; + readonly pgdeltaFormatOptions: string; +} + +/** + * Normalized POST-env-override primitives mirroring Go's decoded config, for VALIDATED fields + * only. Every section is OPTIONAL — an absent section means "this caller doesn't run that Go + * branch, skip it" (e.g. D omits `studio`/`localSmtp` entirely; both D and L omit `auth` when + * auth is disabled). See the module header for the full ported-branch table and out-of-scope + * list. + */ +export interface LegacyConfigValidationInput { + /** L only — D's `project_id` isn't part of `Config.Validate`'s shared surface. */ + readonly projectId?: string; + /** L only — D has no `[api]` section. */ + readonly api?: LegacyApiInput; + /** Both, unconditional in Go. */ + readonly db: LegacyDbInput; + /** Both, unconditional (`[]` = none). */ + readonly storageBucketNames: ReadonlyArray; + /** L only. */ + readonly studio?: LegacyStudioInput; + /** L only. */ + readonly localSmtp?: LegacyLocalSmtpInput; + /** Both, present iff auth is enabled. */ + readonly auth?: LegacyAuthInput; + /** Both, unconditional (`[]` = none). */ + readonly functionSlugs: ReadonlyArray; + /** Both, unconditional. */ + readonly edgeRuntimeDenoVersion: number; + /** Both, unconditional entry (internally gated). */ + readonly analytics: LegacyAnalyticsInput; + /** Both, unconditional entry (internally gated). */ + readonly experimental: LegacyExperimentalInput; +} + +function messageOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * Runs every `Config.Validate` branch this module owns (see the module header's table), in + * Go's exact order, first-failure-wins. Pure — no I/O, no Effect. Callers own their own + * per-section I/O reads (signing keys, `api.tls` cert/key, email template/notification content) + * at the correct Go position themselves, using the pure helpers exported below. + */ +export function legacyValidateResolvedConfig(input: LegacyConfigValidationInput): void { + // config.go:990-991 — checked FIRST, before every other field. + if (input.projectId !== undefined && input.projectId.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: project_id"); + } + + // config.go:1006-1027 — api.port / api.tls.{cert,key}_path, gated on api.enabled. The actual + // cert/key file reads are caller-side I/O (see legacyResolveApiTlsPath below); this only + // checks the "exactly one of cert/key set" presence rule. + if (input.api?.enabled) { + if (input.api.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: api.port"); + } + if (input.api.tls.enabled) { + const hasCert = input.api.tls.certPath !== undefined && input.api.tls.certPath.length > 0; + const hasKey = input.api.tls.keyPath !== undefined && input.api.tls.keyPath.length > 0; + if (hasCert && !hasKey) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.key_path"); + } + if (hasKey && !hasCert) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.cert_path"); + } + } + } + + // config.go:1031-1033 — db.port, unconditional, no `enabled` gate. + if (input.db.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.port"); + } + // config.go:1034-1062 — db.major_version switch: 0 / 12 have dedicated messages, 13/14/15/17 + // are supported, anything else is the generic invalid-value message. + if (input.db.majorVersion === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.major_version"); + } + if (input.db.majorVersion === 12) { + throw new LegacyConfigValidateError( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + if (![13, 14, 15, 17].includes(input.db.majorVersion)) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid db.major_version: ${input.db.majorVersion}.`, + ); + } + + // config.go:1064-1068, pattern @ 1549-1554 — every [storage.buckets.*] key, unconditional. + for (const name of input.storageBucketNames) { + if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { + throw new LegacyConfigValidateError( + `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, + ); + } + } + + // config.go:1070-1079 — studio.port / studio.api_url, gated on studio.enabled. L-only. + if (input.studio?.enabled) { + if (input.studio.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: studio.port"); + } + try { + legacyGoUrlParse(input.studio.apiUrl); + } catch (cause) { + throw new LegacyConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); + } + } + + // config.go:1081-1085 — local_smtp.port, gated on local_smtp.enabled. L-only. + if (input.localSmtp?.enabled && input.localSmtp.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: local_smtp.port"); + } + + // config.go:1087-1153 — the auth.* sub-sequence, all inside `if c.Auth.Enabled`. + if (input.auth !== undefined) { + const auth = input.auth; + + // config.go:1088-1090 — auth.site_url. + if (auth.siteUrl.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: auth.site_url"); + } + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha. The provider enum check runs FIRST, + // regardless of `enabled` (it's actually a decode-time check in Go, reproduced here so both + // callers see it from one place); only then does the `enabled`-gated presence check run. + if (auth.captcha !== undefined) { + const provider = auth.captcha.provider; + if ( + provider !== undefined && + provider.length > 0 && + provider !== "hcaptcha" && + provider !== "turnstile" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + } + if (auth.captcha.enabled) { + if (auth.captcha.provider === undefined) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.provider", + ); + } + if (auth.captcha.secret === undefined || auth.captcha.secret.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.secret", + ); + } + } + } + + // config.go:1110-1116 — signing_keys read is caller-side I/O, not part of this function. + + // config.go:1117-1134 — auth.passkey / auth.webauthn. Caller only builds `passkey` when + // `[auth.passkey] enabled` is true. + if (auth.passkey !== undefined) { + if (!auth.passkey.webauthnPresent) { + throw new LegacyConfigValidateError( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + } + if (auth.passkey.rpId === undefined || auth.passkey.rpId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_id", + ); + } + if (auth.passkey.rpOrigins === undefined || auth.passkey.rpOrigins.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_origins", + ); + } + } + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filtered to + // enabled-only and pre-ordered per Go's fixed hook-type iteration order. + for (const hook of auth.hooks) { + if (hook.uri.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.uri`, + ); + } + const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(hook.uri)?.[1] ?? "").toLowerCase(); + if (scheme === "http" || scheme === "https") { + if (hook.secrets.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.secrets`, + ); + } + for (const secret of hook.secrets.split("|")) { + if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, + ); + } + } + } else if (scheme === "pg-functions") { + if (hook.secrets.length > 0) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets is unsupported for pg-functions URI`, + ); + } + } else { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.uri should be a HTTP, HTTPS, or pg-functions URI`, + ); + } + } + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, caller pre-ordered totp/phone/web_authn. + for (const factor of auth.mfa) { + if (factor.enrollEnabled && !factor.verifyEnabled) { + throw new LegacyConfigValidateError( + `Invalid MFA config: auth.mfa.${factor.label}.enroll_enabled requires verify_enabled`, + ); + } + } + + // config.go:1293-1323 — email template/notification content read + exclusivity is + // caller-side, via legacyResolveEmailTemplateContentPath below. + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + if (auth.smtp !== undefined && auth.smtp.enabled) { + if (auth.smtp.host.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.host", + ); + } + if (auth.smtp.port === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.port", + ); + } + if (auth.smtp.user.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.user", + ); + } + if (auth.smtp.pass.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.pass", + ); + } + if (auth.smtp.adminEmail.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.admin_email", + ); + } + } + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, caller pre-filtered to + // enabled-only and pre-ordered firebase, auth0, cognito, clerk, workos. Each provider's + // required field(s) are checked as encountered; the "more than one enabled" check runs only + // after every entry has individually validated. + for (const thirdParty of auth.thirdParty) { + switch (thirdParty.provider) { + case "firebase": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + } + break; + } + case "auth0": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", + ); + } + break; + } + case "cognito": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + } + if ( + thirdParty.cognitoUserPoolRegion === undefined || + thirdParty.cognitoUserPoolRegion.length === 0 + ) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + } + break; + } + case "clerk": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk is enabled but without a domain.", + ); + } + if (!LEGACY_CLERK_DOMAIN_PATTERN.test(thirdParty.requiredField)) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + ); + } + break; + } + case "workos": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + } + break; + } + } + } + if (auth.thirdParty.length > 1) { + throw new LegacyConfigValidateError( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + } + } + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + for (const slug of input.functionSlugs) { + if (!LEGACY_FUNCTION_SLUG_PATTERN.test(slug)) { + throw new LegacyConfigValidateError( + `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, + ); + } + } + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled. + if (input.edgeRuntimeDenoVersion === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: edge_runtime.deno_version", + ); + } + if (input.edgeRuntimeDenoVersion !== 1 && input.edgeRuntimeDenoVersion !== 2) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid edge_runtime.deno_version: ${input.edgeRuntimeDenoVersion}.`, + ); + } + + // Decode-time enum (`LogflareBackend.UnmarshalText`, config.go:60-65) — reproduced here so + // both callers' env-override paths (which bypass their own decode-time schema guard) see it. + const backend = input.analytics.backend; + if ( + backend !== undefined && + backend.length > 0 && + backend !== "postgres" && + backend !== "bigquery" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", + ); + } + // config.go:1175-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". + if (input.analytics.enabled && backend === "bigquery") { + if (input.analytics.gcpProjectId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_id", + ); + } + if (input.analytics.gcpProjectNumber.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_number", + ); + } + if (input.analytics.gcpJwtPath.length === 0) { + throw new LegacyConfigValidateError( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + } + } + + // config.go:1847-1848 — experimental.webhooks, hinges on TOML-section PRESENCE, not the + // decoded (always-defaulted) `enabled` value. + if (input.experimental.webhooksPresent === true && input.experimental.webhooksEnabled !== true) { + throw new LegacyConfigValidateError( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + } + // config.go:1850-1851 — experimental.pgdelta.format_options, must be valid JSON when set. + if ( + input.experimental.pgdeltaFormatOptions !== "" && + !isValidJson(input.experimental.pgdeltaFormatOptions) + ) { + throw new LegacyConfigValidateError( + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + ); + } +} + +function isValidJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +// ── signing keys (config.go:1110-1116, path rule config.go:877-878 filepath.IsAbs guard) ── + +/** Absolute → verbatim; relative → join(workdir, "supabase", p). */ +export function legacyResolveSigningKeysPath(workdir: string, signingKeysPath: string): string { + return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +} + +/** `failed to read signing keys: ${msg(cause)}` */ +export function legacySigningKeysReadErrorMessage(cause: unknown): string { + return `failed to read signing keys: ${messageOf(cause)}`; +} + +/** `failed to decode signing keys: ${msg(cause)}` */ +export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { + return `failed to decode signing keys: ${messageOf(cause)}`; +} +// D only asserts Array.isArray(JSON.parse(text)); L further decodes into LegacyJwk[] to sign +// with the first key — that JWK-specific decode/signing logic stays in L, unrelated to parity. + +// ── email template / notification (config.go:1293-1323) ── + +/** + * Pure exclusivity decision + path to read for one template/notification entry. Throws + * {@link LegacyConfigValidateError} with the exclusivity message when `contentPath === ""` and + * `contentPresent`. Returns the absolute path to read, or `undefined` when there's nothing to + * read (both `contentPath` and `content` absent — skip, not an error). `contentPath` set (even + * when `content` is ALSO set) always wins — Go does not reject "both set", `content_path` + * silently wins/overwrites. + * + * `base` is caller-resolved: TEMPLATE section → workdir; NOTIFICATION section → + * join(workdir, "supabase") (this asymmetry is real, intentional Go behavior — config.go's own + * FIXME comment flags it, do not "fix" it). + */ +export function legacyResolveEmailTemplateContentPath(args: { + readonly section: "template" | "notification"; + readonly name: string; + /** Post-env-expand; `""` = absent. */ + readonly contentPath: string; + /** 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 LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + ); + } + return undefined; + } + return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); +} + +/** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ +export function legacyEmailContentPathReadErrorMessage( + section: "template" | "notification", + name: string, + cause: unknown, +): string { + return `Invalid config for auth.email.${section}.${name}.content_path: ${messageOf(cause)}`; +} + +// ── api.tls cert/key (config.go:1016-1026, path rule ~961-965, NO isAbsolute guard) ── + +/** Unconditional join(workdir, "supabase", p) — Go's path.Join absorbs a leading "/" too. */ +export function legacyResolveApiTlsPath(workdir: string, p: string): string { + return join(workdir, "supabase", p); +} + +/** `failed to read TLS cert: ${msg(cause)}` */ +export function legacyApiTlsCertReadErrorMessage(cause: unknown): string { + return `failed to read TLS cert: ${messageOf(cause)}`; +} + +/** `failed to read TLS key: ${msg(cause)}` */ +export function legacyApiTlsKeyReadErrorMessage(cause: unknown): string { + return `failed to read TLS key: ${messageOf(cause)}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 1f6ea609bb..467227be80 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; @@ -7,18 +7,37 @@ import { Schema } from "effect"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { - LEGACY_BUCKET_NAME_PATTERN, - LEGACY_CLERK_DOMAIN_PATTERN, - LEGACY_FUNCTION_SLUG_PATTERN, - LEGACY_HOOK_SECRET_PATTERN, + legacyApiTlsCertReadErrorMessage, + legacyApiTlsKeyReadErrorMessage, + type LegacyAnalyticsInput, + type LegacyApiInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + LegacyConfigValidateError, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyLocalSmtpInput, + type LegacyMfaFactorInput, legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveApiTlsPath, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyStudioInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, } from "./legacy-config-validate.ts"; import { legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, type LegacyJwk, } from "./legacy-go-jwt.ts"; -import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** * Go-parity derived local-dev config values, ported from `utils.Config`'s @@ -362,472 +381,128 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * `signing_keys_path`, however stale or missing that file is. */ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { - const absolutePath = isAbsolute(signingKeysPath) - ? signingKeysPath - : join(workdir, "supabase", signingKeysPath); + const absolutePath = legacyResolveSigningKeysPath(workdir, signingKeysPath); let contents: string; try { contents = readFileSync(absolutePath, "utf8"); } catch (cause) { - throw new Error( - `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - ); + throw new LegacyConfigValidateError(legacySigningKeysReadErrorMessage(cause)); } let jwks: ReadonlyArray; try { jwks = decodeLegacyJwks(JSON.parse(contents)); } catch (cause) { - throw new Error( - `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - ); + throw new LegacyConfigValidateError(legacySigningKeysDecodeErrorMessage(cause)); } return jwks[0]; } /** - * Go's `Config.Validate` TLS branch (`pkg/config/config.go:1006-1027`), gated - * on `api.enabled` same as the caller: a cert path with no key path (or vice - * versa) is a hard config error; when both are set, each file is read to - * confirm it's actually reachable, matching Go's `fs.ReadFile` calls (Go - * caches the bytes for `start` to serve as `CertContent`/`KeyContent` — - * `status` has no use for the bytes, only the same validation outcome, so - * they're discarded here). Neither path set is NOT an error in Go — `Validate` - * only rejects the "exactly one set" case, so `tls.enabled = true` with no - * cert/key configured at all still loads (it just can't serve TLS at `start` - * time), mirrored here by returning without throwing. + * Go's `Config.Validate` TLS branch (`pkg/config/config.go:1006-1027`) file reads: gated on + * `api.enabled && api.tls.enabled` same as the caller, each configured path is read to confirm + * it's actually reachable, matching Go's `fs.ReadFile` calls (Go caches the bytes for `start` to + * serve as `CertContent`/`KeyContent` — `status`/`stop` have no use for the bytes, only the same + * validation outcome, so they're discarded here). The "exactly one of cert/key set" presence + * check now lives in `legacyValidateResolvedConfig`'s `api.tls` step + * (`legacy-config-validate.ts`) — this function only runs the reads, and only when BOTH paths + * are actually present: neither path set, or only one, never reaches a `fs.ReadFile` call here, + * since the presence check (run later, as part of the single consolidated validation call) owns + * rejecting the one-but-not-the-other case. * - * Go joins both paths unconditionally with the `supabase/` dir — no - * `filepath.IsAbs` guard (`config.go:961-965` uses `path.Join`, which absorbs - * a leading `/`) — unlike {@link loadFirstSigningKey}'s `signing_keys_path`, - * which Go does guard with `filepath.IsAbs` (`config.go:928-929`). Matches the - * identical Kong-side validation already ported for `seed buckets`/`storage` - * in `legacy-storage-credentials.ts`'s `validateLocalKongTls`. + * Go joins both paths unconditionally with the `supabase/` dir — no `filepath.IsAbs` guard + * (`config.go:961-965` uses `path.Join`, which absorbs a leading `/`) — unlike + * {@link loadFirstSigningKey}'s `signing_keys_path`, which Go does guard with `filepath.IsAbs` + * (`config.go:928-929`). See `legacyResolveApiTlsPath`. Matches the identical Kong-side + * validation already ported for `seed buckets`/`storage` in + * `legacy-storage-credentials.ts`'s `validateLocalKongTls`. * - * Uses `node:fs` directly for the same reason as {@link loadFirstSigningKey}: - * this stays a plain synchronous resolver rather than threading the Effect - * `FileSystem` service through `legacyStatusValues`/`status.handler.ts`. + * Uses `node:fs` directly for the same reason as {@link loadFirstSigningKey}: this stays a plain + * synchronous resolver rather than threading the Effect `FileSystem` service through + * `legacyStatusValues`/`status.handler.ts`. */ -function validateLocalApiTls( +function readApiTlsFiles( workdir: string, certPath: string | undefined, keyPath: string | undefined, ): void { - const hasCert = certPath !== undefined && certPath.length > 0; - const hasKey = keyPath !== undefined && keyPath.length > 0; - - if (hasCert && !hasKey) { - throw new Error("Missing required field in config: api.tls.key_path"); - } - if (hasKey && !hasCert) { - throw new Error("Missing required field in config: api.tls.cert_path"); - } - if (!hasCert) return; + if (certPath === undefined || certPath.length === 0) return; + if (keyPath === undefined || keyPath.length === 0) return; try { - readFileSync(join(workdir, "supabase", certPath), "utf8"); + readFileSync(legacyResolveApiTlsPath(workdir, certPath), "utf8"); } catch (cause) { - throw new Error( - `failed to read TLS cert: ${cause instanceof Error ? cause.message : String(cause)}`, - ); + throw new LegacyConfigValidateError(legacyApiTlsCertReadErrorMessage(cause)); } try { - readFileSync(join(workdir, "supabase", keyPath!), "utf8"); + readFileSync(legacyResolveApiTlsPath(workdir, keyPath), "utf8"); } catch (cause) { - throw new Error( - `failed to read TLS key: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } -} - -/** - * Go's supported `db.major_version` values (`pkg/config/config.go:1039-1040`, the - * `case 13, 14:`/`case 15, 17:` branches — both are no-ops in Go's `Validate`, the - * 15/17 sub-branch only rewrites `Db.Image` for OrioleDB, which `status`/`stop` - * never read). `12` and `0` get their own dedicated messages below; anything else - * falls through to the generic invalid-value message. - */ -const SUPPORTED_DB_MAJOR_VERSIONS = new Set([13, 14, 15, 17]); - -/** - * Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` - * key right after the `db.major_version` switch, unconditionally - * (`pkg/config/config.go:1063-1068`) — unlike `studio.port`/`local_smtp.port` below, - * there is no `storage.enabled`-style gate to check first. Reuses the pattern/message - * already ported for the `db`/migration config-load path - * (`legacy-db-config.toml-read.ts`) and `seed buckets` - * (`commands/seed/buckets/buckets.handler.ts`) rather than duplicating them. - */ -function validateStorageBucketNames(buckets: ProjectConfig["storage"]["buckets"]): void { - if (buckets === undefined) return; - for (const name of Object.keys(buckets)) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - throw new Error( - `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, - ); - } - } -} - -/** - * Go's `Config.Validate` studio.api_url check (`pkg/config/config.go:1074- - * 1078`), gated on the same `studio.enabled` check as the port validation - * right above it in Go's source. Reuses {@link legacyGoUrlParse}'s - * `net/url.Parse` port (already used by the storage commands) rather than a - * TS-only URL/regex check, since Go's own parser is what decides pass/fail - * here — e.g. `http://[::1` (an unterminated IPv6 literal) is a genuine - * `net/url.Parse` failure, not something Go's usual scheme/path leniency - * would let through. Go's success branch also mutates `Studio.ApiUrl` in some - * cases (`config.go:1076-1077`), but neither `status` nor `stop` ever read it - * afterwards (Go's own `status.go` derives `StudioURL` from `Hostname`+`Port` - * directly, matching this resolver's own `studioUrl` field below), so only - * the validation gate — not the mutation — is reproduced. - */ -function validateStudioApiUrl(apiUrl: string): void { - try { - legacyGoUrlParse(apiUrl); - } catch (cause) { - throw new Error( - `Invalid config for studio.api_url: ${cause instanceof Error ? cause.message : String(cause)}`, - ); + throw new LegacyConfigValidateError(legacyApiTlsKeyReadErrorMessage(cause)); } } /** - * Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` key - * right after the auth block/`generateAPIKeys`, before `edge_runtime.deno_version` - * (`pkg/config/config.go:1155-1163`), unconditionally — not gated on `auth.enabled`. - * `@supabase/config`'s own `functions` schema key pattern - * (`packages/config/src/functions.ts`, `/^[a-zA-Z0-9_-]+$/`) is looser than Go's (it - * allows a digit-leading slug Go rejects), so this isn't redundant with decode-time - * validation. Reuses the pattern/message already ported for the `db`/migration - * config-load path (`legacy-db-config.toml-read.ts`). - */ -function validateFunctionSlugs(functions: ProjectConfig["functions"]): void { - for (const name of Object.keys(functions)) { - if (!LEGACY_FUNCTION_SLUG_PATTERN.test(name)) { - throw new Error( - `Invalid Function name: ${name}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, - ); - } - } -} - -/** - * Go's passkey/WebAuthn requirement inside `Config.Validate` - * (`pkg/config/config.go:1117-1129`; the trailing `assertEnvLoaded` calls at - * `1130-1135` are stderr-only warnings that never fail validation, matching - * this file's existing `auth.captcha.secret` precedent, so they have no - * throwing equivalent here). Runs right after the signing-keys read and - * before `Auth.Hook.validate()`, still inside `if c.Auth.Enabled`. + * Go's `(e *email) validate(fsys)` template/notification content read (`pkg/config/ + * config.go:1293-1313`), called from `Config.Validate` right after `Auth.MFA.validate()`, still + * inside `if c.Auth.Enabled` (`config.go:1142`). Every template is checked unconditionally; a + * notification only when that notification is itself enabled (`config.go:1308`). Uses the same + * `readFileSync`-based pattern as {@link loadFirstSigningKey}/`readApiTlsFiles` in this file, + * not an Effect `FileSystem` service. * - * `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all - * (see `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), - * so this reads the RAW, post-`env()`-interpolation TOML document - * (`LoadedProjectConfig.document`) instead of the decoded `ProjectConfig` — - * the same document-based approach already used for the equivalent check on - * the `db`/migration config-load path (`legacy-db-config.toml-read.ts`'s - * `legacyValidateAuthConfig`, section A6). `authDocument` is `undefined` when - * a caller hasn't threaded `document` through yet, in which case this check - * is simply skipped rather than guessed at. Deliberately does not resolve a - * literal `enabled = "env(VAR)"` string the way - * `legacy-db-config.toml-read.ts`'s `gate()` helper does — Go's `Config.Load` - * resolves that generically via Viper for every field, but wiring the same - * `EnvLookup`/`legacyExpandEnv` machinery into this resolver just for this one - * rarely-configured field isn't worth the added surface; a literal TOML - * `enabled = true`/`false` (the overwhelmingly common case) is handled. + * The `content`-vs-`content_path` exclusivity decision and path resolution (including the + * TEMPLATE-vs-`workdir`/NOTIFICATION-vs-`/supabase` base asymmetry, per Go's `(c + * *baseConfig) resolve` (`config.go:900-916`) — this asymmetry is real, intentional Go behavior + * to match, not a bug to fix) now live in `legacyResolveEmailTemplateContentPath` + * (`legacy-config-validate.ts`); this function only feeds it `contentPresent` (computed from the + * raw `document`, since `@supabase/config`'s `template`/`notification` schema + * (`packages/config/src/auth/email.ts`) has no `content` field to see) and performs the read + * when a path comes back. */ -function validatePasskeyWebauthn(authDocument: Record | undefined): void { - const passkey = asRecord(authDocument?.["passkey"]); - if (passkey?.["enabled"] !== true) return; - const webauthn = asRecord(authDocument?.["webauthn"]); - if (webauthn === undefined) { - throw new Error( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - } - if (typeof webauthn["rp_id"] !== "string" || webauthn["rp_id"].length === 0) { - throw new Error("Missing required field in config: auth.webauthn.rp_id"); - } - const rpOrigins = webauthn["rp_origins"]; - if (!Array.isArray(rpOrigins) || rpOrigins.length === 0) { - throw new Error("Missing required field in config: auth.webauthn.rp_origins"); - } -} - -/** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`). */ -const LEGACY_HOOK_TYPES = [ - "mfa_verification_attempt", - "password_verification_attempt", - "custom_access_token", - "send_sms", - "send_email", - "before_user_created", -] as const; - -/** - * Go's `(h *hook) validate()` / `(h *hookConfig) validate(hookType)` - * (`pkg/config/config.go:1453-1521`), called from `Config.Validate` right after the - * signing-keys/passkey checks and before `Auth.MFA.validate()` — all inside - * `if c.Auth.Enabled` (`config.go:1136-1139`). Each enabled hook requires a `uri`; - * an http(s) scheme requires non-empty `secrets` matching Go's `hookSecretPattern` - * (one or more `|`-separated values), while `pg-functions` forbids secrets outright - * and any other scheme is rejected. Scheme extraction mirrors Go's lenient - * `net/url.Parse` (which, unlike `new URL`, doesn't throw on a schemeless URI). - * Reuses the secret pattern already ported for the `db`/migration config-load path - * (`legacy-db-config.toml-read.ts`) rather than duplicating it. - */ -function validateAuthHooks(hook: ProjectConfig["auth"]["hook"]): void { - for (const hookType of LEGACY_HOOK_TYPES) { - const h = hook[hookType]; - if (!h.enabled) continue; - if (h.uri === undefined || h.uri.length === 0) { - throw new Error(`Missing required field in config: auth.hook.${hookType}.uri`); - } - const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(h.uri)?.[1] ?? "").toLowerCase(); - if (scheme === "http" || scheme === "https") { - if (h.secrets === undefined || h.secrets.length === 0) { - throw new Error(`Missing required field in config: auth.hook.${hookType}.secrets`); - } - for (const secret of h.secrets.split("|")) { - if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) { - throw new Error( - `Invalid hook config: auth.hook.${hookType}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, - ); - } - } - } else if (scheme === "pg-functions") { - if (h.secrets !== undefined && h.secrets.length > 0) { - throw new Error( - `Invalid hook config: auth.hook.${hookType}.secrets is unsupported for pg-functions URI`, - ); - } - } else { - throw new Error( - `Invalid hook config: auth.hook.${hookType}.uri should be a HTTP, HTTPS, or pg-functions URI`, - ); - } - } -} - -/** - * Go's `(m *mfa) validate()` (`pkg/config/config.go:1523-1534`), called from - * `Config.Validate` right after `Auth.Hook.validate()` and before - * `Auth.Email`/`Auth.Sms`/`Auth.External`/`Auth.ThirdParty` (`config.go:1139`) — all - * inside `if c.Auth.Enabled`. Each of the three MFA factors independently requires - * `verify_enabled` whenever `enroll_enabled` is set; `@supabase/config`'s `mfa` - * schema (`packages/config/src/auth/mfa.ts`) has no cross-field refinement, so - * nothing catches this at decode time either. - */ -function validateMfaConfig(mfa: ProjectConfig["auth"]["mfa"]): void { - if (mfa.totp.enroll_enabled && !mfa.totp.verify_enabled) { - throw new Error("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled"); - } - if (mfa.phone.enroll_enabled && !mfa.phone.verify_enabled) { - throw new Error("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled"); - } - if (mfa.web_authn.enroll_enabled && !mfa.web_authn.verify_enabled) { - throw new Error( - "Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled", - ); - } -} - -/** - * Go's `(tpa *thirdParty) validate()` (`pkg/config/config.go:1635-1683`), called - * from `Config.Validate` right after `Auth.Email.validate()` (`config.go:1151- - * 1153`, itself right after `Auth.MFA.validate()`) — all inside `if - * c.Auth.Enabled`. Sms/External sit between Email and ThirdParty in Go - * (`config.go:1145-1150`) but aren't ported by this resolver yet, matching the - * existing pattern of documented-but-unported gaps in this file. Each provider - * enabled without its required field fails immediately (Go checks each - * provider's fields before moving to the next); only once every enabled - * provider individually validates does the "more than one enabled" check run. - * `assertEnvLoaded` WARN-only calls (`config.go:1567-1602`) aren't ported, same - * as this file's existing `auth.captcha.secret` precedent. - */ -function validateThirdPartyAuth(thirdParty: ProjectConfig["auth"]["third_party"]): void { - let enabledCount = 0; +function readAuthEmailTemplateContent( + email: ProjectConfig["auth"]["email"], + workdir: string, + authDocument: Record | undefined, +): void { + const emailDoc = asRecord(authDocument?.["email"]); + const templatesDoc = asRecord(emailDoc?.["template"]); + const notificationsDoc = asRecord(emailDoc?.["notification"]); - if (thirdParty.firebase.enabled) { - enabledCount += 1; - if ( - thirdParty.firebase.project_id === undefined || - thirdParty.firebase.project_id.length === 0 - ) { - throw new Error( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - } - } - if (thirdParty.auth0.enabled) { - enabledCount += 1; - if (thirdParty.auth0.tenant === undefined || thirdParty.auth0.tenant.length === 0) { - throw new Error("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); - } - } - if (thirdParty.aws_cognito.enabled) { - enabledCount += 1; - if ( - thirdParty.aws_cognito.user_pool_id === undefined || - thirdParty.aws_cognito.user_pool_id.length === 0 - ) { - throw new Error( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - } - if ( - thirdParty.aws_cognito.user_pool_region === undefined || - thirdParty.aws_cognito.user_pool_region.length === 0 - ) { - throw new Error( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); - } - } - if (thirdParty.clerk.enabled) { - enabledCount += 1; - const domain = thirdParty.clerk.domain; - if (domain === undefined || domain.length === 0) { - throw new Error("Invalid config: auth.third_party.clerk is enabled but without a domain."); - } - if (!LEGACY_CLERK_DOMAIN_PATTERN.test(domain)) { - throw new Error( - "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + for (const [name, tmpl] of Object.entries(email.template)) { + const path = legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: tmpl.content_path, + contentPresent: asRecord(templatesDoc?.[name])?.["content"] !== undefined, + base: workdir, + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("template", name, cause), ); } } - if (thirdParty.workos.enabled) { - enabledCount += 1; - if (thirdParty.workos.issuer_url === undefined || thirdParty.workos.issuer_url.length === 0) { - throw new Error( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + for (const [name, tmpl] of Object.entries(email.notification)) { + if (!tmpl.enabled) continue; + const path = legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: tmpl.content_path, + contentPresent: asRecord(notificationsDoc?.[name])?.["content"] !== undefined, + base: join(workdir, "supabase"), + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("notification", name, cause), ); } } - - if (enabledCount > 1) { - throw new Error( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - } -} - -/** - * Go's `(e *email) validate(fsys)` template/notification file-existence - * checks (`pkg/config/config.go:1293-1313`), called from `Config.Validate` - * right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` - * (`config.go:1142`). Every template's `content_path` is checked - * unconditionally; a notification's only when that notification is itself - * enabled (`config.go:1308`). Uses the same `readFileSync`-based pattern as - * {@link loadFirstSigningKey}/{@link validateLocalApiTls} in this file, not an - * Effect `FileSystem` service. - * - * Relative paths resolve against different bases, per Go's `(c *baseConfig) - * resolve` (`config.go:900-916`, flagged there with a `// FIXME: only email - * template is relative to repo directory`): TEMPLATE paths resolve against - * the project root (`workdir`), NOTIFICATION paths resolve against - * `/supabase` — this asymmetry is intentional Go behavior to match, - * not a bug to fix. - * - * Go's `content`-vs-`content_path` mutual-exclusivity check (`config.go:1296- - * 1298,1313-1315`) isn't reproduced: `@supabase/config`'s `template`/ - * `notification` schema (`packages/config/src/auth/email.ts`) has no - * `content` field at all, so that branch can never trigger through the - * decoded config — a documented, deliberate gap (unlike the SMTP/analytics - * checks elsewhere in this file, which are actively ported). - */ -function validateAuthEmailTemplates(email: ProjectConfig["auth"]["email"], workdir: string): void { - for (const [name, tmpl] of Object.entries(email.template)) { - if (tmpl.content_path.length === 0) continue; - readEmailContentPath("template", name, tmpl.content_path, workdir); - } - for (const [name, tmpl] of Object.entries(email.notification)) { - if (!tmpl.enabled || tmpl.content_path.length === 0) continue; - readEmailContentPath("notification", name, tmpl.content_path, join(workdir, "supabase")); - } -} - -function readEmailContentPath( - section: "template" | "notification", - name: string, - contentPath: string, - base: string, -): void { - const absolutePath = isAbsolute(contentPath) ? contentPath : join(base, contentPath); - try { - readFileSync(absolutePath, "utf8"); - } catch (cause) { - throw new Error( - `Invalid config for auth.email.${section}.${name}.content_path: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } -} - -/** - * Go's `[auth.email.smtp]` presence-based `enabled` default - * (`pkg/config/config.go:743-748`): when the TOML table is present but omits - * an `enabled` key, Go sets `auth.email.smtp.enabled = true` on the raw viper - * map BEFORE the struct is decoded — a genuinely presence-based default, not - * a struct-tag one (the Go struct's own zero-value is `false`). That defaulted - * value then gates the required-field check in `(e *email) validate(fsys)` - * (`config.go:1325-1341`), called from `Auth.Email.validate()` - * (`config.go:1142`), still inside `if c.Auth.Enabled`. - * - * `@supabase/config`'s schema (`packages/config/src/auth/email.ts`) defaults - * `smtp.enabled` to `false` at DECODE time regardless of whether the table is - * present, which erases exactly the presence signal this check needs — same - * shape of gap as {@link validatePasskeyWebauthn}/{@link validateExperimentalConfig}, - * so this reads the raw `document` instead of the decoded `ProjectConfig`. - * Mirrors the equivalent, already-correct check on the `db`/migration - * config-load path (`legacy-db-config.toml-read.ts`, section B3). - */ -function validateAuthEmailSmtp(emailDocument: Record | undefined): void { - const smtp = asRecord(emailDocument?.["smtp"]); - if (smtp === undefined) return; - const smtpEnabled = smtp["enabled"] === undefined ? true : smtp["enabled"] === true; - if (!smtpEnabled) return; - if (typeof smtp["host"] !== "string" || smtp["host"].length === 0) { - throw new Error("Missing required field in config: auth.email.smtp.host"); - } - if (typeof smtp["port"] !== "number" || smtp["port"] === 0) { - throw new Error("Missing required field in config: auth.email.smtp.port"); - } - if (typeof smtp["user"] !== "string" || smtp["user"].length === 0) { - throw new Error("Missing required field in config: auth.email.smtp.user"); - } - if (typeof smtp["pass"] !== "string" || smtp["pass"].length === 0) { - throw new Error("Missing required field in config: auth.email.smtp.pass"); - } - if (typeof smtp["admin_email"] !== "string" || smtp["admin_email"].length === 0) { - throw new Error("Missing required field in config: auth.email.smtp.admin_email"); - } -} - -/** - * Go's `Config.Validate`'s `switch c.Db.MajorVersion` (`pkg/config/config.go: - * 1034-1061`): `0` is the zero-value/missing case, `12` has a dedicated - * unsupported-version message (with a migration-docs link), `13`/`14`/`15`/`17` - * are supported (the 15/17 OrioleDB image-rewrite sub-branch is skipped here — - * irrelevant to `status`/`stop`, which never read `db.image`), and anything else - * is the generic invalid-value message. Mirrors the equivalent check already - * ported for the `db query`/`test db` path (`legacy-db-config.toml-read.ts: - * 1414-1429`), except this one honors Go's exact `case 0:` message rather than - * folding it into the generic "Invalid db.major_version" text. - */ -function validateDbMajorVersion(majorVersion: number): void { - if (majorVersion === 0) { - throw new Error("Missing required field in config: db.major_version"); - } - if (majorVersion === 12) { - throw new Error( - "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", - ); - } - if (!SUPPORTED_DB_MAJOR_VERSIONS.has(majorVersion)) { - throw new Error(`Failed reading config: Invalid db.major_version: ${majorVersion}.`); - } } /** @@ -836,7 +511,7 @@ function validateDbMajorVersion(majorVersion: number): void { * — same generic Viper `AutomaticEnv` binding (`config.go:576-586`), same * mapstructure hard-fail-on-bad-value semantics as the port/bool overrides, but * with no upper-bound cap. A non-digit override folds into the same generic - * "Invalid db.major_version" message {@link validateDbMajorVersion} produces for + * "Invalid db.major_version" message `legacyValidateResolvedConfig` produces for * an out-of-set numeric value, since Go's own decode failure and `Validate` * failure for this field aren't independently distinguishable from the CLI's * output the way ports/bools are. @@ -853,32 +528,12 @@ function envOverrideMajorVersion( return Number(value); } -/** - * Go's `Config.Validate`'s `switch c.EdgeRuntime.DenoVersion` (`pkg/config/ - * config.go:1164-1173`): `0` is the zero-value/missing case, `1`/`2` are - * supported (the `1` sub-branch only rewrites `EdgeRuntime.Image` to the - * `deno1` tag, which `status`/`stop` never read), and anything else is the - * generic invalid-value message. Unlike `studio.port`/`local_smtp.port`, this - * switch is NOT nested inside an `edge_runtime.enabled` gate — it runs - * unconditionally, so a disabled edge runtime with an invalid `deno_version` - * still fails config loading. Mirrors the equivalent check already ported for - * the `db diff`/pg-delta path (`legacy-db-config.toml-read.ts:1482-1499`). - */ -function validateDenoVersion(denoVersion: number): void { - if (denoVersion === 0) { - throw new Error("Missing required field in config: edge_runtime.deno_version"); - } - if (denoVersion !== 1 && denoVersion !== 2) { - throw new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`); - } -} - /** * `SUPABASE_EDGE_RUNTIME_DENO_VERSION` sibling of {@link envOverrideMajorVersion} * — same generic Viper `AutomaticEnv` binding, same mapstructure * hard-fail-on-bad-value semantics, no upper-bound cap. A non-digit override * folds into the same generic "Invalid edge_runtime.deno_version" message - * {@link validateDenoVersion} produces for an out-of-set numeric value. + * `legacyValidateResolvedConfig` produces for an out-of-set numeric value. */ function envOverrideDenoVersion( configured: number, @@ -899,54 +554,17 @@ function asRecord(value: unknown): Record | undefined { : undefined; } -/** - * Go's `(e *experimental) validate()` (`pkg/config/config.go:1846-1854`), - * called from `Config.Validate` right after the analytics/bigquery block and - * right before `Validate` returns (`config.go:1188-1190`) — unconditionally, - * with no `enabled`-style gate of its own. - * - * The webhooks check is NOT "the user disabled a feature" — Go's bool - * zero-value is `false`, so `e.Webhooks != nil && !e.Webhooks.Enabled` rejects - * ANY present `[experimental.webhooks]` section whose `enabled` isn't - * explicitly `true`, including one where the key is simply omitted; the - * section exists only so it can be turned on, never explicitly off. This - * hinges on PRESENCE of the TOML section, not the decoded `enabled` value — - * unlike the doc comment one might expect from `Schema.optionalKey`, - * `@supabase/config`'s decode-time default (`packages/config/src/ - * experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills in - * `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` - * even when the TOML section is entirely absent — verified empirically, this - * default-fill erases exactly the presence signal this check needs. So this - * reads `LoadedProjectConfig.document` (the raw, pre-default TOML) instead, - * the same document-based approach {@link validatePasskeyWebauthn} uses for a - * field the schema doesn't model at all. - */ -function validateExperimentalConfig( - experimental: ProjectConfig["experimental"], - experimentalDocument: Record | undefined, -): void { - if ( - asRecord(experimentalDocument?.["webhooks"]) !== undefined && - experimental.webhooks?.enabled !== true - ) { - throw new Error( - "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", - ); - } - const formatOptions = experimental.pgdelta?.format_options; - if (formatOptions !== undefined && formatOptions.length > 0 && !isValidJson(formatOptions)) { - throw new Error("Invalid config for experimental.pgdelta.format_options: must be valid JSON"); - } -} - -function isValidJson(value: string): boolean { - try { - JSON.parse(value); - return true; - } catch { - return false; - } -} +/** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`), used + * only to build {@link legacyResolveLocalConfigValues}'s `hooks` input in the right order — + * the actual per-hook validation now lives in `legacyValidateResolvedConfig`. */ +const LEGACY_HOOK_TYPE_ORDER = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", +] as const; /** * @throws when `project_id` (post-override) is an explicit empty string. Go's @@ -964,51 +582,26 @@ function isValidJson(value: string): boolean { * override doesn't parse as a valid port. * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv * override doesn't parse as a valid bool. - * @throws when `api.tls.enabled` is set with only one of `cert_path`/`key_path`, or a - * configured file can't be read — see {@link validateLocalApiTls}. - * @throws when `api.enabled` is true and `api.port` (post-override) is `0`. - * @throws when `db.port` (post-override) is `0`. - * @throws when `db.major_version` (post-override) is `0`, `12`, or otherwise - * unsupported — see {@link validateDbMajorVersion}. - * @throws when a `[storage.buckets.*]` key doesn't match Go's bucket-name pattern — - * see {@link validateStorageBucketNames}. Unconditional, no `storage.enabled` gate. - * @throws when `auth.enabled` is true, `[auth.passkey] enabled` is `true` (per the - * raw `document`), and `[auth.webauthn]` is missing or incomplete — see - * {@link validatePasskeyWebauthn}. Skipped when `document` isn't provided. - * @throws when `auth.enabled` is true and an enabled `[auth.hook.*]` entry's `uri` - * or `secrets` fails Go's scheme/secret-pattern rules — see {@link validateAuthHooks}. - * @throws when `auth.enabled` is true and an MFA factor's `enroll_enabled` is set - * without `verify_enabled` — see {@link validateMfaConfig}. - * @throws when `auth.enabled` is true and an email template's `content_path` - * (or an enabled notification's) can't be read — see - * {@link validateAuthEmailTemplates}. - * @throws when `auth.enabled` is true, `[auth.email.smtp]` is present (per the - * raw `document`) and not explicitly disabled, and `host`/`port`/`user`/`pass`/ - * `admin_email` isn't set — see {@link validateAuthEmailSmtp}. Skipped when - * `document` isn't provided. - * @throws when `auth.enabled` is true and an enabled `[auth.third_party.*]` - * provider is missing its required field, or more than one provider is enabled - * at once — see {@link validateThirdPartyAuth}. - * @throws when a `[functions.*]` key doesn't match Go's function-slug pattern — - * see {@link validateFunctionSlugs}. Unconditional, not gated on `auth.enabled`. - * @throws when `edge_runtime.deno_version` (post-override) is `0` or otherwise - * not `1`/`2` — see {@link validateDenoVersion}. Unconditional, not gated on - * `edge_runtime.enabled`. - * @throws when `studio.enabled` is true and `studio.port` (post-override) is `0`, - * or `studio.api_url` (post-override) fails Go's `net/url.Parse` — see - * {@link validateStudioApiUrl}. - * @throws when `local_smtp.enabled` is true and `local_smtp.port` (post-override) is `0`. - * @throws when `auth.enabled` is true and `auth.site_url` is empty. - * @throws when `auth.enabled` is true, `auth.captcha.enabled` (post-override) is true, - * and `auth.captcha.provider` or `auth.captcha.secret` is unset. + * @throws when a configured `api.tls` cert/key file can't be read — see + * {@link readApiTlsFiles}. The "exactly one of cert/key set" presence check + * runs later, as part of {@link legacyValidateResolvedConfig}. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, - * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. - * @throws when `analytics.enabled` (post-override) is true, `analytics.backend` - * (post-override) is `"bigquery"`, and `analytics.gcp_project_id`, - * `analytics.gcp_project_number`, or `analytics.gcp_jwt_path` is unset. - * @throws when a `[experimental.webhooks]` section is present without an - * explicit `enabled = true`, or `experimental.pgdelta.format_options` is set - * but isn't valid JSON — see {@link validateExperimentalConfig}. + * or its first key uses an unsupported algorithm — see {@link loadFirstSigningKey} + * and {@link legacyGenerateAsymmetricGoJwt}. + * @throws 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 {LegacyInvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't parse as one of Go's `LogflareBackend` values. + * @throws {LegacyConfigValidateError} for every other `Config.Validate` branch this module + * and `legacy-config-validate.ts` jointly own — project_id emptiness aside (checked above, + * inline, since the value is also needed for the throw's own message-free early-exit shape), + * every REMAINING pure check (api.port/tls presence, db.port/major_version, storage bucket + * names, studio, local_smtp, auth.site_url/captcha/passkey/hooks/mfa/smtp/third_party, + * function slugs, edge_runtime.deno_version, analytics.gcp_*, experimental.*) is deferred to a + * SINGLE call to {@link legacyValidateResolvedConfig} at the end of this function, in Go's exact + * relative order — see that module's header for the full table and the accepted ordering + * tradeoff this introduces against the I/O checks listed above (which keep running at their + * original position, per-caller, rather than being folded into that single call). */ export function legacyResolveLocalConfigValues( config: ProjectConfig, @@ -1019,10 +612,11 @@ export function legacyResolveLocalConfigValues( * `LoadedProjectConfig.document` (`packages/config/src/io.ts`) — the raw, * pre-schema-default TOML document `config` was decoded from. Lets checks * that hinge on TOML-section PRESENCE (not the decoded, always-defaulted - * value) inspect the file directly — see {@link validateExperimentalConfig} - * and {@link validatePasskeyWebauthn}. `undefined` for callers that haven't - * threaded it through yet (e.g. most existing unit tests); those checks are - * then simply skipped rather than guessed at. + * value) inspect the file directly — see `legacyValidateResolvedConfig`'s + * `experimental.webhooks`/`auth.passkey`/`auth.email.smtp` steps. + * `undefined` for callers that haven't threaded it through yet (e.g. most + * existing unit tests); those checks are then simply skipped rather than + * guessed at. */ document: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { @@ -1034,9 +628,6 @@ export function legacyResolveLocalConfigValues( // (`config.go:529-535`) and it can turn an explicit-empty file value back // into a valid override. const resolvedProjectId = envOverride("SUPABASE_PROJECT_ID", config.project_id, projectEnvValues); - if (resolvedProjectId !== undefined && resolvedProjectId.length === 0) { - throw new Error("Missing required field in config: project_id"); - } // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ @@ -1060,12 +651,18 @@ export function legacyResolveLocalConfigValues( "api.enabled", projectEnvValues, ); + const apiTlsCertPath = envOverride( + "SUPABASE_API_TLS_CERT_PATH", + config.api.tls.cert_path, + projectEnvValues, + ); + const apiTlsKeyPath = envOverride( + "SUPABASE_API_TLS_KEY_PATH", + config.api.tls.key_path, + projectEnvValues, + ); if (apiEnabled && apiTlsEnabled) { - validateLocalApiTls( - workdir, - envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues), - envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues), - ); + readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } // Go's `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY // when `api.enabled` (`pkg/config/config.go:1006-1008`) — unlike `db.port` @@ -1078,9 +675,6 @@ export function legacyResolveLocalConfigValues( "api.port", projectEnvValues, ); - if (apiEnabled && apiPort === 0) { - throw new Error("Missing required field in config: api.port"); - } const apiExternalUrl = legacyResolveApiExternalUrl( { external_url: envOverride( @@ -1098,24 +692,15 @@ export function legacyResolveLocalConfigValues( // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` // render anything, same wording already used for the `db query`/`test db` - // path (`legacy-db-config.toml-read.ts:1380`). This check is intentionally - // NOT inside `envOverridePort` itself: that helper is generic across all - // four port fields, and Go's zero-rejection for the other three is - // conditional on their section's `enabled` flag (`config.go:1006-1009, - // 1070-1073,1081-1084`), so adding it there would wrongly reject e.g. - // `SUPABASE_STUDIO_PORT=0` even when `studio.enabled` is `false`. + // path (`legacy-db-config.toml-read.ts:1380`). const dbPort = envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); - if (dbPort === 0) { - throw new Error("Missing required field in config: db.port"); - } // Go's `Config.Validate` checks `db.major_version` right after `db.port` // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); - validateDbMajorVersion(majorVersion); // Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` - // key right after `db.major_version`, unconditionally — see - // {@link validateStorageBucketNames}. - validateStorageBucketNames(config.storage.buckets); + // key right after `db.major_version`, unconditionally. + const storageBucketNames = + config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same // enabled-gated pattern as `api.port` above. @@ -1131,21 +716,15 @@ export function legacyResolveLocalConfigValues( "studio.port", projectEnvValues, ); - if (studioEnabled) { - if (studioPort === 0) { - throw new Error("Missing required field in config: studio.port"); - } - // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` - // right after the port check, still inside `if c.Studio.Enabled` - // (`pkg/config/config.go:1074-1078`) — see {@link validateStudioApiUrl}. - // `config.studio.api_url` is a required (defaulted) field, so `envOverride` - // can only return `undefined` here if that default itself were somehow - // undefined — the `??` fallback just satisfies that generic signature. - validateStudioApiUrl( - envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url, - ); - } + // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` right + // after the port check, still inside `if c.Studio.Enabled` + // (`pkg/config/config.go:1074-1078`). `config.studio.api_url` is a required + // (defaulted) field, so `envOverride` can only return `undefined` here if + // that default itself were somehow undefined — the `??` fallback just + // satisfies that generic signature. + const studioApiUrl = + envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url; // Go's `Config.Validate` rejects `local_smtp.port === 0`/ // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct // field is still named `Inbucket` for the `[local_smtp]` TOML section @@ -1164,9 +743,6 @@ export function legacyResolveLocalConfigValues( "local_smtp.port", projectEnvValues, ); - if (mailpitEnabled && mailpitPort === 0) { - throw new Error("Missing required field in config: local_smtp.port"); - } const jwtSecret = resolveJwtSecret( envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), ); @@ -1196,9 +772,6 @@ export function legacyResolveLocalConfigValues( // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as // `""` with no schema-level error, same gap as `db.port === 0` above. const siteUrl = envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues); - if (authEnabled && (siteUrl === undefined || siteUrl.length === 0)) { - throw new Error("Missing required field in config: auth.site_url"); - } // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`): an // enabled CAPTCHA section requires both `provider` and `secret`. Read @@ -1208,18 +781,14 @@ export function legacyResolveLocalConfigValues( // this file for this nested optional auth sub-section. `config.auth.captcha` // is genuinely `undefined` when `[auth.captcha]` is absent from config.toml // (`Schema.optionalKey` in `packages/config/src/auth/index.ts`), matching - // Go's `*Captcha` pointer being nil — `?.enabled` is falsy in that case, same - // as Go's `c.Auth.Captcha != nil && c.Auth.Captcha.Enabled` guard. Go's own - // `assertEnvLoaded` warning on the secret (`config.go:1106-1108`) never fails - // validation, so it has no throwing equivalent here. - if (authEnabled && config.auth.captcha?.enabled) { - if (config.auth.captcha.provider === undefined) { - throw new Error("Missing required field in config: auth.captcha.provider"); - } - if (config.auth.captcha.secret === undefined || config.auth.captcha.secret.length === 0) { - throw new Error("Missing required field in config: auth.captcha.secret"); - } - } + // Go's `*Captcha` pointer being nil. + const captchaInput: LegacyCaptchaInput | undefined = config.auth.captcha + ? { + enabled: config.auth.captcha.enabled ?? false, + provider: config.auth.captcha.provider, + secret: config.auth.captcha.secret, + } + : undefined; const signingKey = authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) @@ -1229,37 +798,147 @@ export function legacyResolveLocalConfigValues( // `Auth.Email.validate()`, then (skipping the unported Sms/External steps) // `Auth.ThirdParty.validate()`, all right after the signing-keys read and // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1117-1153`). - // Sms/External (`config.go:1145-1150`) aren't ported. + // Sms/External (`config.go:1145-1150`) aren't ported (D-only, see + // `legacy-config-validate.ts`'s module header). This block only ACCUMULATES + // the inputs those checks need — the checks themselves run once, later, as + // part of the single `legacyValidateResolvedConfig` call below. + let authInput: LegacyAuthInput | undefined; if (authEnabled) { + // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see + // `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), so passkey/webauthn + // are read from the RAW, post-`env()`-interpolation TOML document + // (`LoadedProjectConfig.document`) instead of the decoded `ProjectConfig` — same document-based + // approach already used on the `db`/migration config-load path + // (`legacy-db-config.toml-read.ts`'s `legacyValidateAuthConfig`, section A6). `authDocument` is + // `undefined` when a caller hasn't threaded `document` through yet, in which case passkey/smtp + // presence-based checks are simply skipped rather than guessed at. const authDocument = asRecord(document?.["auth"]); - validatePasskeyWebauthn(authDocument); - validateAuthHooks(config.auth.hook); - validateMfaConfig(config.auth.mfa); - validateAuthEmailTemplates(config.auth.email, workdir); - validateAuthEmailSmtp(asRecord(authDocument?.["email"])); - validateThirdPartyAuth(config.auth.third_party); + const passkeyDoc = asRecord(authDocument?.["passkey"]); + const webauthnDoc = asRecord(authDocument?.["webauthn"]); + const passkey: LegacyPasskeyInput | undefined = + passkeyDoc?.["enabled"] === true + ? { + webauthnPresent: webauthnDoc !== undefined, + rpId: typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined, + rpOrigins: Array.isArray(webauthnDoc?.["rp_origins"]) + ? webauthnDoc["rp_origins"] + : undefined, + } + : undefined; + + // Go's `hook.validate()` fixed iteration order (`pkg/config/config.go:1453-1485`) — only + // enabled hooks are forwarded, in that order. + const hooks: Array = []; + for (const hookType of LEGACY_HOOK_TYPE_ORDER) { + const hook = config.auth.hook[hookType]; + if (hook.enabled) { + hooks.push({ type: hookType, uri: hook.uri ?? "", secrets: hook.secrets ?? "" }); + } + } + + const mfa: ReadonlyArray = [ + { + label: "totp", + enrollEnabled: config.auth.mfa.totp.enroll_enabled, + verifyEnabled: config.auth.mfa.totp.verify_enabled, + }, + { + label: "phone", + enrollEnabled: config.auth.mfa.phone.enroll_enabled, + verifyEnabled: config.auth.mfa.phone.verify_enabled, + }, + { + label: "web_authn", + enrollEnabled: config.auth.mfa.web_authn.enroll_enabled, + verifyEnabled: config.auth.mfa.web_authn.verify_enabled, + }, + ]; + + // Go's `Config.Validate` runs the email template/notification content read right after + // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` (`config.go:1142`) — this I/O read + // stays at this exact textual position (see this function's `@throws` doc for why). + readAuthEmailTemplateContent(config.auth.email, workdir, authDocument); + + // Go's `[auth.email.smtp]` presence-based `enabled` default (`pkg/config/config.go:743-748`): + // when the TOML table is present but omits `enabled`, Go treats it as `true` — a genuinely + // presence-based default `@supabase/config`'s schema can't see (it always decodes + // `smtp.enabled` to `false` when the key is absent), so this reads the raw `document` too. + const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); + const smtp: LegacySmtpInput | undefined = + smtpDoc !== undefined + ? { + enabled: smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + host: typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", + port: typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + user: typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", + pass: typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", + adminEmail: typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", + } + : undefined; + + // Go's `(tpa *thirdParty) validate()` fixed provider order (`pkg/config/config.go:1635-1683`) + // — only enabled providers are forwarded, in that order. + const thirdParty: Array = []; + if (config.auth.third_party.firebase.enabled) { + thirdParty.push({ + provider: "firebase", + requiredField: config.auth.third_party.firebase.project_id ?? "", + }); + } + if (config.auth.third_party.auth0.enabled) { + thirdParty.push({ + provider: "auth0", + requiredField: config.auth.third_party.auth0.tenant ?? "", + }); + } + if (config.auth.third_party.aws_cognito.enabled) { + thirdParty.push({ + provider: "cognito", + requiredField: config.auth.third_party.aws_cognito.user_pool_id ?? "", + cognitoUserPoolRegion: config.auth.third_party.aws_cognito.user_pool_region, + }); + } + if (config.auth.third_party.clerk.enabled) { + thirdParty.push({ + provider: "clerk", + requiredField: config.auth.third_party.clerk.domain ?? "", + }); + } + if (config.auth.third_party.workos.enabled) { + thirdParty.push({ + provider: "workos", + requiredField: config.auth.third_party.workos.issuer_url ?? "", + }); + } + + authInput = { + siteUrl: siteUrl ?? "", + captcha: captchaInput, + passkey, + hooks, + mfa, + smtp, + thirdParty, + }; } // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` - // key right after the auth block/`generateAPIKeys`, unconditionally — see - // {@link validateFunctionSlugs}. - validateFunctionSlugs(config.functions); + // key right after the auth block/`generateAPIKeys`, unconditionally. + const functionSlugs = Object.keys(config.functions); // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth // block and the functions loop (`pkg/config/config.go:1158-1173`), and — // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no // `edge_runtime.enabled` gate. const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); - validateDenoVersion(denoVersion); // Go's `Config.Validate` validates `[analytics]` right after - // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`, inside - // `func (c *config) Validate` at `config.go:989`): when `analytics.enabled` - // and `analytics.backend == "bigquery"`, all three GCP fields are required, - // checked in that order, each with its own message. Backend-enum validation - // (rejecting a non-postgres/bigquery value) is covered at decode time for - // the `config.toml`-sourced value by `@supabase/config`'s `stringEnum` - // (`packages/config/src/analytics.ts:17-41`), but that schema doesn't see - // the `SUPABASE_ANALYTICS_BACKEND` env-override path — see - // {@link envOverrideAnalyticsBackend} for that case. + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order, each with its own message. + // Backend-enum validation (rejecting a non-postgres/bigquery value) is + // covered at decode time for the `config.toml`-sourced value by + // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), + // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override + // path — see {@link envOverrideAnalyticsBackend} for that case. const analyticsEnabled = legacyEnvOverrideBool( "SUPABASE_ANALYTICS_ENABLED", config.analytics.enabled, @@ -1267,39 +946,91 @@ export function legacyResolveLocalConfigValues( projectEnvValues, ); const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); - if (analyticsEnabled && analyticsBackend === "bigquery") { - const gcpProjectId = envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - config.analytics.gcp_project_id, - projectEnvValues, - ); - if (gcpProjectId === undefined || gcpProjectId.length === 0) { - throw new Error("Missing required field in config: analytics.gcp_project_id"); - } - const gcpProjectNumber = envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - config.analytics.gcp_project_number, - projectEnvValues, - ); - if (gcpProjectNumber === undefined || gcpProjectNumber.length === 0) { - throw new Error("Missing required field in config: analytics.gcp_project_number"); - } - const gcpJwtPath = envOverride( - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - config.analytics.gcp_jwt_path, - projectEnvValues, - ); - if (gcpJwtPath === undefined || gcpJwtPath.length === 0) { - throw new Error( - "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", - ); - } - } + const gcpProjectId = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + const gcpProjectNumber = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + const gcpJwtPath = envOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); // Go's `Config.Validate` calls `c.Experimental.validate()` right after the - // analytics/bigquery block and right before returning — see - // {@link validateExperimentalConfig}. - validateExperimentalConfig(config.experimental, asRecord(document?.["experimental"])); + // analytics/bigquery block and right before returning. The webhooks check is NOT "the user + // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && + // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` + // isn't explicitly `true`, including one where the key is simply omitted; the section exists + // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML + // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default + // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills + // in `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` even when the + // TOML section is entirely absent — verified empirically, this default-fill erases exactly the + // presence signal this check needs. So this reads `LoadedProjectConfig.document` (the raw, + // pre-default TOML) instead, same as the passkey/smtp checks above. + const experimentalDocument = asRecord(document?.["experimental"]); + const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; + const webhooksEnabled = config.experimental.webhooks?.enabled === true; + const pgdeltaFormatOptions = config.experimental.pgdelta?.format_options ?? ""; + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is + // deferred to this single call, positioned here (where the last of those checks ran until + // this commit), in Go's exact relative order against every OTHER pure check. This means a + // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers + // first among the ones broken — unchanged from before. The only real reordering risk is + // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls + // cert/key, email template/notification content) that in THIS function's source sits between + // two pure sections (e.g. the signing-keys read sits between the captcha check above and the + // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O + // read now effectively runs BEFORE those later pure checks rather than interleaved at its + // original relative position. This is the same narrow, accepted, documented tradeoff recorded + // in `legacy-config-validate.ts`'s module header; every existing test constructs exactly one + // validation failure at a time, so it has zero effect on any real test. + const apiInput: LegacyApiInput = { + enabled: apiEnabled, + port: apiPort, + tls: { enabled: apiTlsEnabled, certPath: apiTlsCertPath, keyPath: apiTlsKeyPath }, + }; + const dbInput: LegacyDbInput = { port: dbPort, majorVersion }; + const studioInput: LegacyStudioInput = { + enabled: studioEnabled, + port: studioPort, + apiUrl: studioApiUrl, + }; + const localSmtpInput: LegacyLocalSmtpInput = { enabled: mailpitEnabled, port: mailpitPort }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend, + gcpProjectId: gcpProjectId ?? "", + gcpProjectNumber: gcpProjectNumber ?? "", + gcpJwtPath: gcpJwtPath ?? "", + }; + const experimentalInput: LegacyExperimentalInput = { + webhooksPresent, + webhooksEnabled, + pgdeltaFormatOptions, + }; + + const input: LegacyConfigValidationInput = { + projectId: resolvedProjectId, + api: apiInput, + db: dbInput, + storageBucketNames, + studio: studioInput, + localSmtp: localSmtpInput, + auth: authInput, + functionSlugs, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + legacyValidateResolvedConfig(input); return { apiUrl: apiExternalUrl, From 0c62a914307519c2bde72ef1180bee0ec8275345 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 15:05:18 +0100 Subject: [PATCH 78/81] fix(cli): validate db config through legacy-config-validate, correcting db.major_version=0 parity legacyReadDbToml now builds a LegacyConfigValidationInput/LegacyAuthInput adapter and runs every Go Config.Validate branch it shares with legacy-local-config-values.ts (db port/major_version, storage bucket names, function slugs, edge_runtime.deno_version, analytics backend/bigquery fields, experimental.pgdelta.format_options, and the auth site_url/captcha/passkey/hooks/mfa/email-smtp/third_party sequence) through the single shared legacyValidateResolvedConfig call, instead of duplicating that logic inline. This fixes the db.major_version = 0 divergence: it previously fell through to the generic "Failed reading config: Invalid db.major_version: 0." message instead of Go's "Missing required field in config: db.major_version". auth.sms and auth.external stay 100% inline in D (never part of the shared validator, per legacy-config-validate.ts's module header) and now run after the shared call succeeds, still gated on auth.enabled. --- .../shared/legacy-db-config.toml-read.ts | 978 ++++++++---------- .../legacy-db-config.toml-read.unit.test.ts | 22 + 2 files changed, 459 insertions(+), 541 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 1fb9c9ab96..892aaf3dae 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,12 +1,25 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; import { - LEGACY_BUCKET_NAME_PATTERN, - LEGACY_CLERK_DOMAIN_PATTERN, - LEGACY_FUNCTION_SLUG_PATTERN, - LEGACY_HOOK_SECRET_PATTERN, LEGACY_PROJECT_REF_PATTERN, + type LegacyAnalyticsInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyMfaFactorInput, legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, } from "./legacy-config-validate.ts"; import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; @@ -590,16 +603,6 @@ function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } -/** Go's `json.Valid` (`encoding/json`): reports whether the string is well-formed JSON. */ -function legacyIsValidJson(value: string): boolean { - try { - JSON.parse(value); - return true; - } catch { - return false; - } -} - /** * Resolve a `[section] enabled` style bool. Go decodes a TOML bool natively and a * string (incl. an `env(VAR)` reference) via `strconv.ParseBool` — so `"1"`/`"t"`/etc. @@ -740,400 +743,6 @@ const legacyAssertDecryptableSecrets = ( // `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; -/** - * Ports the FATAL validations Go runs inside `if c.Auth.Enabled { … }` during - * `config.Validate` (`apps/cli-go/pkg/config/config.go:1036-1102` + the nested - * `.validate()` methods at 1242-1632), in Go's first-failure-wins order, so a db/migration - * command aborts on an invalid auth config exactly like the Go CLI (the reviewer's case: - * `migration down --local` must stop before any destructive work). Every check below mirrors - * a Go `return errors.New(...)` site with the byte-exact message. - * - * Deliberately NOT ported: the `assertEnvLoaded` WARN lines (Go's `config.go:1143-1148` only - * prints a stderr warning and always returns nil — never fatal), and the non-fatal mutations - * (the SMS "no provider → disable phone login" WARN, the linkedin/slack deprecation WARN); - * those affect neither the exit code nor any value this subset reader exposes. The - * linkedin/slack providers are still skipped (Go deletes them before validating). - */ -const legacyValidateAuthConfig = Effect.fnUntraced(function* ( - authRaw: RawDoc, - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - lookup: EnvLookup, -) { - const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); - const supabaseDir = path.join(workdir, "supabase"); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` - // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. - const str = (rec: RawDoc | undefined, key: string): string => { - const value = rec?.[key]; - return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; - }; - // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. - // A malformed string ABORTS the load like Go's decode (it does NOT coerce to false), using - // the reader's `failed to parse config: invalid .` shape (the same simplification the - // db.* bools use; Go's verbose mapstructure string is not reproduced byte-for-byte there - // either). Absent / non-string → false (the default for every auth enable-flag). - const gate = (rec: RawDoc | undefined, key: string, field: string) => - Effect.gen(function* () { - const value = rec?.[key]; - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value !== "string") return false; - const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); - if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); - return parsed; - }); - // Resolve a config file path: absolute → verbatim (Go opens it from the OS root after chdir); - // relative → joined under `base` (`filepath.IsAbs` guards, `config.go:854-878`). - const resolvePath = (p: string, base: string): string => - path.isAbsolute(p) ? p : path.join(base, p); - - // A1: site_url required (`config.go:1037-1039`). - const siteUrl = - authRaw["site_url"] === undefined ? DEFAULT_AUTH_SITE_URL : str(authRaw, "site_url"); - if (siteUrl.length === 0) return yield* fail("Missing required field in config: auth.site_url"); - - // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider.UnmarshalText`, - // `auth.go:58-71`) that fires whenever `provider` is set, regardless of `enabled`; the - // required-field checks run only when enabled (`config.go:1048-1058`). - const captcha = asRecord(authRaw["captcha"]); - if (captcha !== undefined) { - const provider = str(captcha, "provider"); - if (provider.length > 0 && provider !== "hcaptcha" && provider !== "turnstile") - return yield* fail( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", - ); - if (yield* gate(captcha, "enabled", "auth.captcha.enabled")) { - if (provider.length === 0) - return yield* fail("Missing required field in config: auth.captcha.provider"); - if (str(captcha, "secret").length === 0) - return yield* fail("Missing required field in config: auth.captcha.secret"); - } - } - - // A5: signing keys file load (`config.go:1059-1065`) — read + parse as a JSON array. A - // relative path resolves under the supabase dir (`config.go:877-878`); absolute is verbatim. - const signingKeysPath = str(authRaw, "signing_keys_path"); - if (signingKeysPath.length > 0) { - const keysJson = yield* fs.readFileString(resolvePath(signingKeysPath, supabaseDir)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `failed to read signing keys: ${cause.message}`, - }), - ), - ); - yield* Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(keysJson); - if (!Array.isArray(parsed)) throw new Error("signing keys must be a JSON array of JWKs"); - return parsed; - }, - catch: (cause) => - new LegacyDbConfigLoadError({ - message: `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - } - - // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). - const passkey = asRecord(authRaw["passkey"]); - if (passkey !== undefined && (yield* gate(passkey, "enabled", "auth.passkey.enabled"))) { - const webauthn = asRecord(authRaw["webauthn"]); - if (webauthn === undefined) - return yield* fail( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - if (str(webauthn, "rp_id").length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_id"); - const rpOrigins = webauthn["rp_origins"]; - if (!Array.isArray(rpOrigins) || rpOrigins.length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_origins"); - } - - // B1: hooks — each enabled hook (`config.go:1402-1470`). - const hook = asRecord(authRaw["hook"]); - if (hook !== undefined) { - const hookTypes = [ - "mfa_verification_attempt", - "password_verification_attempt", - "custom_access_token", - "send_sms", - "send_email", - "before_user_created", - ] as const; - for (const hookType of hookTypes) { - const h = asRecord(hook[hookType]); - if (h === undefined) continue; - if (!(yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) continue; - const uri = str(h, "uri"); - if (uri.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.uri`); - // Go uses net/url.Parse, which (unlike `new URL`) does not throw on a missing scheme; - // extract the scheme the same lenient way so a no-scheme/other URI hits the default - // branch rather than erroring (the rare url.Parse error case is not separately ported). - const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(uri)?.[1] ?? "").toLowerCase(); - const secrets = str(h, "secrets"); - if (scheme === "http" || scheme === "https") { - if (secrets.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.secrets`); - for (const secret of secrets.split("|")) { - if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, - ); - } - } else if (scheme === "pg-functions") { - if (secrets.length > 0) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets is unsupported for pg-functions URI`, - ); - } else { - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.uri should be a HTTP, HTTPS, or pg-functions URI`, - ); - } - } - } - - // B2: mfa — enroll requires verify (`config.go:1472-1483`). - const mfa = asRecord(authRaw["mfa"]); - if (mfa !== undefined) { - for (const [key, label] of [ - ["totp", "totp"], - ["phone", "phone"], - ["web_authn", "web_authn"], - ] as const) { - const factor = asRecord(mfa[key]); - if (factor === undefined) continue; - const enroll = yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`); - const verify = yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`); - if (enroll && !verify) - return yield* fail( - `Invalid MFA config: auth.mfa.${label}.enroll_enabled requires verify_enabled`, - ); - } - } - - // B3: email (`config.go:1242-1295`). - const email = asRecord(authRaw["email"]); - if (email !== undefined) { - // Go resolves a relative `content_path` differently per section: email TEMPLATE paths are - // relative to the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), while - // NOTIFICATION paths are relative to the supabase dir (`config.go:861-862`); absolute → as-is. - const validateTemplate = ( - section: "template" | "notification", - name: string, - tmpl: RawDoc, - base: string, - ) => - Effect.gen(function* () { - const contentPath = str(tmpl, "content_path"); - if (contentPath.length === 0) { - if (tmpl["content"] !== undefined) - return yield* fail( - `Invalid config for auth.email.${section}.${name}.content: please use content_path instead`, - ); - return; - } - yield* fs.readFileString(resolvePath(contentPath, base)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `Invalid config for auth.email.${section}.${name}.content_path: ${cause.message}`, - }), - ), - ); - }); - const templates = asRecord(email["template"]); - if (templates !== undefined) { - for (const name of Object.keys(templates)) { - const tmpl = asRecord(templates[name]); - if (tmpl !== undefined) yield* validateTemplate("template", name, tmpl, workdir); - } - } - const notifications = asRecord(email["notification"]); - if (notifications !== undefined) { - for (const name of Object.keys(notifications)) { - const tmpl = asRecord(notifications[name]); - if ( - tmpl !== undefined && - (yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) - ) - yield* validateTemplate("notification", name, tmpl, supabaseDir); - } - } - // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present - // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly - // disabled. - const smtp = asRecord(email["smtp"]); - const smtpEnabled = - smtp !== undefined && - (smtp["enabled"] === undefined - ? true - : yield* gate(smtp, "enabled", "auth.email.smtp.enabled")); - if (smtp !== undefined && smtpEnabled) { - if (str(smtp, "host").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.host"); - const portRaw = smtp["port"]; - const port = - typeof portRaw === "number" - ? portRaw - : typeof portRaw === "string" - ? Number(legacyExpandEnv(portRaw, lookup)) - : 0; - if (!port) return yield* fail("Missing required field in config: auth.email.smtp.port"); - if (str(smtp, "user").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.user"); - if (str(smtp, "pass").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.pass"); - if (str(smtp, "admin_email").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.admin_email"); - } - } - - // B4: sms — only the FIRST enabled provider is validated (Go's switch, `config.go:1297-1364`). - const sms = asRecord(authRaw["sms"]); - if (sms !== undefined) { - const twilio = asRecord(sms["twilio"]); - const twilioVerify = asRecord(sms["twilio_verify"]); - const messagebird = asRecord(sms["messagebird"]); - const textlocal = asRecord(sms["textlocal"]); - const vonage = asRecord(sms["vonage"]); - // Resolve every provider's enable-flag (a malformed bool aborts like Go's decode); Go's - // switch then validates only the FIRST enabled provider. - const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); - const twilioVerifyEnabled = yield* gate( - twilioVerify, - "enabled", - "auth.sms.twilio_verify.enabled", - ); - const messagebirdEnabled = yield* gate(messagebird, "enabled", "auth.sms.messagebird.enabled"); - const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); - const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); - if (twilioEnabled) { - if (str(twilio, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); - if (str(twilio, "message_service_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.message_service_sid"); - if (str(twilio, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); - } else if (twilioVerifyEnabled) { - if (str(twilioVerify, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.account_sid"); - if (str(twilioVerify, "message_service_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio_verify.message_service_sid", - ); - if (str(twilioVerify, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); - } else if (messagebirdEnabled) { - if (str(messagebird, "originator").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); - if (str(messagebird, "access_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); - } else if (textlocalEnabled) { - if (str(textlocal, "sender").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); - if (str(textlocal, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); - } else if (vonageEnabled) { - if (str(vonage, "from").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.from"); - if (str(vonage, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); - if (str(vonage, "api_secret").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); - } - } - - // B5: external providers (`config.go:1368-1398`). linkedin/slack are deprecated and deleted - // before validation, so they are never validated here. - const external = asRecord(authRaw["external"]); - if (external !== undefined) { - for (const name of Object.keys(external)) { - if (name === "linkedin" || name === "slack") continue; - const provider = asRecord(external[name]); - if (provider === undefined) continue; - if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; - if (str(provider, "client_id").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); - if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.secret`); - } - } - - // B6: third_party — validate each enabled provider in order, then mutual exclusivity - // (`config.go:1584-1632`). Note `aws_cognito`'s messages say `cognito` (Go's wording). - const thirdParty = asRecord(authRaw["third_party"]); - if (thirdParty !== undefined) { - let enabledCount = 0; - const firebase = asRecord(thirdParty["firebase"]); - if ( - firebase !== undefined && - (yield* gate(firebase, "enabled", "auth.third_party.firebase.enabled")) - ) { - enabledCount += 1; - if (str(firebase, "project_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - } - const auth0 = asRecord(thirdParty["auth0"]); - if (auth0 !== undefined && (yield* gate(auth0, "enabled", "auth.third_party.auth0.enabled"))) { - enabledCount += 1; - if (str(auth0, "tenant").length === 0) - return yield* fail( - "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", - ); - } - const cognito = asRecord(thirdParty["aws_cognito"]); - if ( - cognito !== undefined && - (yield* gate(cognito, "enabled", "auth.third_party.aws_cognito.enabled")) - ) { - enabledCount += 1; - if (str(cognito, "user_pool_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - if (str(cognito, "user_pool_region").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); - } - const clerk = asRecord(thirdParty["clerk"]); - if (clerk !== undefined && (yield* gate(clerk, "enabled", "auth.third_party.clerk.enabled"))) { - enabledCount += 1; - const domain = str(clerk, "domain"); - if (domain.length === 0) - return yield* fail( - "Invalid config: auth.third_party.clerk is enabled but without a domain.", - ); - if (!LEGACY_CLERK_DOMAIN_PATTERN.test(domain)) - return yield* fail( - "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", - ); - } - const workos = asRecord(thirdParty["workos"]); - if ( - workos !== undefined && - (yield* gate(workos, "enabled", "auth.third_party.workos.enabled")) - ) { - enabledCount += 1; - if (str(workos, "issuer_url").length === 0) - return yield* fail( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", - ); - } - if (enabledCount > 1) - return yield* fail( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - } -}); - /** * Reads `/supabase/config.toml` (db subtree + project id) and the linked * `/supabase/.temp/pooler-url`. `fs`/`path` are passed in so the resolver @@ -1376,22 +985,10 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( }), ); } - // Reject unsupported major versions like Go's config.Validate ({13,14,15,17}; - // `apps/cli-go/pkg/config/config.go:869-897`) before any image/container runs. An - // absent value falls through to the default (Go's zero-then-default). - if ( - typeof majorVersionResolved === "number" && - ![13, 14, 15, 17].includes(majorVersionResolved) - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - majorVersionResolved === 12 - ? "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects." - : `Failed reading config: Invalid db.major_version: ${majorVersionResolved}.`, - }), - ); - } + // Rejecting an unsupported major version ({13,14,15,17}; `apps/cli-go/pkg/config/config.go: + // 869-897`) is now `legacyValidateResolvedConfig`'s `db.major_version` switch (called once, + // below) — an absent value falls through to the default (Go's zero-then-default) and a present + // one (including `0`) flows into `input.db.majorVersion` for that switch to check. const majorVersion = typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; @@ -1444,25 +1041,10 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( }), ); } - // Go's config.Validate rejects a present-but-invalid deno_version before pg-delta - // runs (`config.go:999-1008`): 0 → missing-required, anything other than 1/2 → - // invalid. An absent key falls through to the default (Go merges deno_version=2). - if (typeof denoVersionResolved === "number") { - if (denoVersionResolved === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: edge_runtime.deno_version", - }), - ); - } - if (denoVersionResolved !== 1 && denoVersionResolved !== 2) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${denoVersionResolved}.`, - }), - ); - } - } + // Rejecting a present-but-invalid deno_version (`config.go:999-1008`: 0 → missing-required, + // anything other than 1/2 → invalid) is now `legacyValidateResolvedConfig`'s + // `edgeRuntimeDenoVersion` switch (called once, below). An absent key falls through to the + // default (Go merges deno_version=2). const denoVersion = typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; @@ -1546,60 +1128,19 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), lookup, ); - // Go's config.Validate aborts config load when a non-empty format_options is not - // valid JSON (`apps/cli-go/pkg/config/config.go:1685-1686`), before any shadow / - // catalog container runs. Fail here with Go's exact message so the user gets the - // actionable error up front rather than a later `JSON.parse` failure in the script. - if (formatOptionsExpanded.length > 0 && !legacyIsValidJson(formatOptionsExpanded)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - }), - ); - } + // Rejecting a non-empty, non-JSON `format_options` (`apps/cli-go/pkg/config/config.go: + // 1685-1686`) is now `legacyValidateResolvedConfig`'s `experimental.pgdeltaFormatOptions` + // check (called once, below). const formatOptions = nonEmptyString(formatOptionsExpanded); - // Go's config.Validate runs `ValidateBucketName` over every `[storage.buckets.*]` - // key on load (`apps/cli-go/pkg/config/config.go:898-903`), rejecting the config - // before any db command when a bucket name does not match `bucketNamePattern`. - // The reader otherwise drops `storage.buckets`, so port the check here with Go's - // exact message (the trailing `(%s)` is the regex source, `config.go:1386`). + // Bucket-name/function-slug validation (`config.go:898-903`/`993-998`) now lives in + // `legacyValidateResolvedConfig` (called once, below); only the pure extraction stays here. const bucketsRaw = asRecord(storageRaw?.["buckets"]); - if (bucketsRaw !== undefined) { - for (const name of Object.keys(bucketsRaw)) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, - }), - ); - } - } - } - - // Go's config.Validate runs `ValidateFunctionSlug` over every `[functions.*]` key on - // load (`apps/cli-go/pkg/config/config.go:993-998`, immediately after the bucket loop), - // rejecting the config before any db command when a slug does not match - // `funcSlugPattern`. The reader otherwise drops `functions`, so port the check here - // with Go's exact message (the trailing `(%s)` is the regex source, `config.go:1376`). - if (functionsRaw !== undefined) { - for (const name of Object.keys(functionsRaw)) { - if (!LEGACY_FUNCTION_SLUG_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Function name: ${name}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, - }), - ); - } - } - } - // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) - // after the bucket/function checks — port its fatal validations so db/migration commands - // abort on an invalid auth config exactly like Go (e.g. an enabled passkey without a valid - // [auth.webauthn], or two third_party providers). Gated on `auth.enabled` (default true). - // Go's viper AutomaticEnv binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate - // (`config.go:529-535`), so the env override decides whether the auth block is validated. + // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) after + // the bucket/function checks. Gated on `auth.enabled` (default true); Go's viper AutomaticEnv + // binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate (`config.go:529-535`), so the + // env override decides whether the auth block is validated. const authEnabled = yield* resolveBoolOrFail( "auth.enabled", authRaw?.["enabled"], @@ -1607,41 +1148,300 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( lookup, envOverride("SUPABASE_AUTH_ENABLED"), ); + + // Local helpers mirroring the deleted `legacyValidateAuthConfig`'s closures — its Go-parity + // CHECKS now live in `legacyValidateResolvedConfig`; `str`/`gate`/`fail` are still needed here + // to build that call's `LegacyAuthInput`, and by the D-only sms/external checks below (never + // part of the shared validator — see `legacy-config-validate.ts`'s module header). + const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); + // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` + // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. + const str = (rec: RawDoc | undefined, key: string): string => { + const value = rec?.[key]; + return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; + }; + // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. A + // malformed string ABORTS the load like Go's decode (it does NOT coerce to false). Absent / + // non-string → false (the default for every auth enable-flag). + const gate = (rec: RawDoc | undefined, key: string, field: string) => + Effect.gen(function* () { + const value = rec?.[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return false; + const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); + if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); + return parsed; + }); + + const authRawResolved = authRaw ?? {}; + let authInput: LegacyAuthInput | undefined; if (authEnabled) { - yield* legacyValidateAuthConfig(authRaw ?? {}, fs, path, workdir, lookup); + // A1: site_url required (`config.go:1037-1039`). + const siteUrl = + authRawResolved["site_url"] === undefined + ? DEFAULT_AUTH_SITE_URL + : str(authRawResolved, "site_url"); + + // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider. + // UnmarshalText`, `auth.go:58-71`); the required-field checks are `enabled`-gated + // (`config.go:1048-1058`) — both now live in `legacyValidateResolvedConfig`. + const captchaRaw = asRecord(authRawResolved["captcha"]); + let captchaInput: LegacyCaptchaInput | undefined; + if (captchaRaw !== undefined) { + const provider = str(captchaRaw, "provider"); + const secret = str(captchaRaw, "secret"); + captchaInput = { + enabled: yield* gate(captchaRaw, "enabled", "auth.captcha.enabled"), + // `str()` returns `""` for an absent key, but the shared validator's + // `provider === undefined` check needs a real `undefined` to fire correctly for an + // enabled captcha with no provider set. + provider: provider.length > 0 ? provider : undefined, + secret: secret.length > 0 ? secret : undefined, + }; + } + + // A5: signing keys file load (`config.go:1059-1065`) — I/O, stays in D. A relative path + // resolves under the supabase dir; absolute is verbatim. + const signingKeysPath = str(authRawResolved, "signing_keys_path"); + if (signingKeysPath.length > 0) { + const keysJson = yield* fs + .readFileString(legacyResolveSigningKeysPath(workdir, signingKeysPath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysReadErrorMessage(cause) }), + ), + ); + yield* Effect.try({ + try: () => { + const parsed: unknown = JSON.parse(keysJson); + if (!Array.isArray(parsed)) { + throw new Error("signing keys must be a JSON array of JWKs"); + } + return parsed; + }, + catch: (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysDecodeErrorMessage(cause) }), + }); + } + + // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). + const passkeyRaw = asRecord(authRawResolved["passkey"]); + let passkeyInput: LegacyPasskeyInput | undefined; + if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { + const webauthnRaw = asRecord(authRawResolved["webauthn"]); + const rpOrigins = webauthnRaw?.["rp_origins"]; + passkeyInput = { + webauthnPresent: webauthnRaw !== undefined, + rpId: str(webauthnRaw, "rp_id"), + rpOrigins: Array.isArray(rpOrigins) ? rpOrigins : undefined, + }; + } + + // B1: hooks — each enabled hook, Go's fixed iteration order (`config.go:1402-1470`). + const hookRaw = asRecord(authRawResolved["hook"]); + const hookTypes = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", + ] as const; + const hooks: Array = []; + for (const hookType of hookTypes) { + const h = asRecord(hookRaw?.[hookType]); + if (h !== undefined && (yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) { + hooks.push({ type: hookType, uri: str(h, "uri"), secrets: str(h, "secrets") }); + } + } + + // B2: mfa — enroll requires verify (`config.go:1472-1483`), fixed totp/phone/web_authn order. + const mfaRaw = asRecord(authRawResolved["mfa"]); + const mfa: Array = []; + for (const label of ["totp", "phone", "web_authn"] as const) { + const factor = asRecord(mfaRaw?.[label]); + mfa.push({ + label, + enrollEnabled: yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`), + verifyEnabled: yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`), + }); + } + + // B3: email (`config.go:1242-1295`) — template/notification content is I/O, stays in D. Go + // resolves a relative `content_path` differently per section: TEMPLATE paths are relative to + // the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), NOTIFICATION paths are + // relative to the supabase dir (`config.go:861-862`); absolute → as-is. + const emailRaw = asRecord(authRawResolved["email"]); + const templatesRaw = asRecord(emailRaw?.["template"]); + if (templatesRaw !== undefined) { + for (const name of Object.keys(templatesRaw)) { + const tmpl = asRecord(templatesRaw[name]); + if (tmpl === undefined) continue; + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("template", name, cause), + }), + ), + ); + } + } + const notificationsRaw = asRecord(emailRaw?.["notification"]); + if (notificationsRaw !== undefined) { + for (const name of Object.keys(notificationsRaw)) { + const tmpl = asRecord(notificationsRaw[name]); + if ( + tmpl === undefined || + !(yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) + ) { + continue; + } + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: supabaseDir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("notification", name, cause), + }), + ), + ); + } + } + // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present + // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly + // disabled. + const smtpRaw = asRecord(emailRaw?.["smtp"]); + let smtpInput: LegacySmtpInput | undefined; + if (smtpRaw !== undefined) { + const smtpPortRaw = smtpRaw["port"]; + // The shared validator's required-field check is `port === 0` (Go decodes `port` into a + // numeric type at the config-decode step, so it can never observe a non-numeric value here). + // D reads the raw TOML/env string directly, so a non-numeric `port` (or an unresolved + // `env(VAR)`) parses to `NaN` via `Number(...)` — normalize that to `0` so it still trips + // the "missing required field" check instead of silently passing config load. + const smtpPortNumeric = + typeof smtpPortRaw === "number" + ? smtpPortRaw + : typeof smtpPortRaw === "string" + ? Number(legacyExpandEnv(smtpPortRaw, lookup)) + : 0; + smtpInput = { + enabled: + smtpRaw["enabled"] === undefined + ? true + : yield* gate(smtpRaw, "enabled", "auth.email.smtp.enabled"), + host: str(smtpRaw, "host"), + port: Number.isNaN(smtpPortNumeric) ? 0 : smtpPortNumeric, + user: str(smtpRaw, "user"), + pass: str(smtpRaw, "pass"), + adminEmail: str(smtpRaw, "admin_email"), + }; + } + + // B6: third_party — each enabled provider, Go's fixed order (`config.go:1584-1632`). Note + // `aws_cognito`'s messages say `cognito` (Go's wording). + const thirdPartyRaw = asRecord(authRawResolved["third_party"]); + const thirdParty: Array = []; + const firebaseRaw = asRecord(thirdPartyRaw?.["firebase"]); + if ( + firebaseRaw !== undefined && + (yield* gate(firebaseRaw, "enabled", "auth.third_party.firebase.enabled")) + ) { + thirdParty.push({ provider: "firebase", requiredField: str(firebaseRaw, "project_id") }); + } + const auth0Raw = asRecord(thirdPartyRaw?.["auth0"]); + if ( + auth0Raw !== undefined && + (yield* gate(auth0Raw, "enabled", "auth.third_party.auth0.enabled")) + ) { + thirdParty.push({ provider: "auth0", requiredField: str(auth0Raw, "tenant") }); + } + const cognitoRaw = asRecord(thirdPartyRaw?.["aws_cognito"]); + if ( + cognitoRaw !== undefined && + (yield* gate(cognitoRaw, "enabled", "auth.third_party.aws_cognito.enabled")) + ) { + thirdParty.push({ + provider: "cognito", + requiredField: str(cognitoRaw, "user_pool_id"), + cognitoUserPoolRegion: str(cognitoRaw, "user_pool_region"), + }); + } + const clerkRaw = asRecord(thirdPartyRaw?.["clerk"]); + if ( + clerkRaw !== undefined && + (yield* gate(clerkRaw, "enabled", "auth.third_party.clerk.enabled")) + ) { + thirdParty.push({ provider: "clerk", requiredField: str(clerkRaw, "domain") }); + } + const workosRaw = asRecord(thirdPartyRaw?.["workos"]); + if ( + workosRaw !== undefined && + (yield* gate(workosRaw, "enabled", "auth.third_party.workos.enabled")) + ) { + thirdParty.push({ provider: "workos", requiredField: str(workosRaw, "issuer_url") }); + } + + authInput = { + siteUrl, + captcha: captchaInput, + passkey: passkeyInput, + hooks, + mfa, + smtp: smtpInput, + thirdParty, + }; } // Go's config.Validate validates `[analytics]` after the auth block (`config.go:1123-1135`). - // Two fatal checks run on the db/migration path: - // 1. `LogflareBackend.UnmarshalText` (`config.go:60-66`) is a decode-time enum that rejects - // any `backend` other than `postgres`/`bigquery` — regardless of `enabled` (it fires - // during UnmarshalExact, like the captcha provider enum), so it gates here too. - // 2. When analytics is enabled with the BigQuery backend, the three GCP fields are required - // (`config.go:1124-1134`), in order, with byte-exact messages. - // Go merges the template defaults `enabled = true`, `backend = "postgres"` before Validate - // (`templates/config.toml:388-392`), so an absent `[analytics]` section is enabled+postgres and - // passes (an empty backend never equals `bigquery`, so the GCP block is skipped). viper - // AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a matched remote block makes those keys env-immune, - // same as every other `LEGACY_ENV_OVERRIDABLE_KEYS` field above. + // Computed here (after the auth block, before the single shared call) rather than pure-derived + // earlier: `analyticsEnabled` resolves through `resolveBoolOrFail`, which can itself FAIL on a + // malformed `SUPABASE_ANALYTICS_ENABLED`/`analytics.enabled` bool — positioning that failure + // point ahead of the auth block would report it before an auth-block error even for a config + // that's ALSO broken there, reversing Go's real order. Go merges the template defaults + // `enabled = true`, `backend = "postgres"` before Validate (`templates/config.toml:388-392`), so + // an absent `[analytics]` section is enabled+postgres and passes (an empty backend never equals + // `bigquery`, so the GCP block is skipped). viper AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a + // matched remote block makes those keys env-immune, same as every other + // `LEGACY_ENV_OVERRIDABLE_KEYS` field above. const analyticsString = (key: string, envName: string): string => { const fromEnv = remoteOverrideKeys.has(`analytics.${key}`) ? undefined : envOverride(envName); const raw = fromEnv ?? analyticsRaw?.[key]; return typeof raw === "string" ? legacyExpandEnv(raw, lookup) : ""; }; const analyticsBackend = analyticsString("backend", "SUPABASE_ANALYTICS_BACKEND"); - if ( - analyticsBackend.length > 0 && - analyticsBackend !== "postgres" && - analyticsBackend !== "bigquery" - ) { - // Mirror the captcha enum's mapstructure envelope (`%v` of the allowed `[]LogflareBackend`). - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", - }), - ); - } const analyticsEnabled = yield* resolveBoolOrFail( "analytics.enabled", analyticsRaw?.["enabled"], @@ -1651,32 +1451,128 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), ); - if (analyticsEnabled && analyticsBackend === "bigquery") { - // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays - // non-empty and passes the `len(...) == 0` check, exactly like Go. - if (analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_id", - }), + // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays + // non-empty and passes the shared validator's `length === 0` check, exactly like Go. + const gcpProjectId = analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID"); + const gcpProjectNumber = analyticsString( + "gcp_project_number", + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + ); + const gcpJwtPath = analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH"); + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own (db.port + // is checked earlier, above, and stays there — see the comment at that check) is deferred to + // this single call, in Go's exact relative order. D's sms/external checks (D-only, never part + // of the shared validator) run AFTER this call succeeds, still gated on `authEnabled` — see + // `legacy-config-validate.ts`'s module header for the accepted ordering tradeoff this + // introduces against third_party. + const dbInput: LegacyDbInput = { port, majorVersion }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend.length > 0 ? analyticsBackend : undefined, + gcpProjectId, + gcpProjectNumber, + gcpJwtPath, + }; + const experimentalInput: LegacyExperimentalInput = { + pgdeltaFormatOptions: formatOptionsExpanded, + }; + const validationInput: LegacyConfigValidationInput = { + db: dbInput, + storageBucketNames: bucketsRaw !== undefined ? Object.keys(bucketsRaw) : [], + functionSlugs: functionsRaw !== undefined ? Object.keys(functionsRaw) : [], + auth: authInput, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + yield* Effect.try({ + try: () => legacyValidateResolvedConfig(validationInput), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + if (authEnabled) { + // B4: sms — D-only (`config.go:1145-1147`/`1348-1417`, never part of the shared validator, + // see `legacy-config-validate.ts`'s module header). Only the FIRST enabled provider is + // validated (Go's switch). + const sms = asRecord(authRawResolved["sms"]); + if (sms !== undefined) { + const twilio = asRecord(sms["twilio"]); + const twilioVerify = asRecord(sms["twilio_verify"]); + const messagebird = asRecord(sms["messagebird"]); + const textlocal = asRecord(sms["textlocal"]); + const vonage = asRecord(sms["vonage"]); + const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); + const twilioVerifyEnabled = yield* gate( + twilioVerify, + "enabled", + "auth.sms.twilio_verify.enabled", ); - } - if ( - analyticsString("gcp_project_number", "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER").length === 0 - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_number", - }), + const messagebirdEnabled = yield* gate( + messagebird, + "enabled", + "auth.sms.messagebird.enabled", ); + const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); + const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); + if (twilioEnabled) { + if (str(twilio, "account_sid").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); + if (str(twilio, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio.message_service_sid", + ); + if (str(twilio, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); + } else if (twilioVerifyEnabled) { + if (str(twilioVerify, "account_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.account_sid", + ); + if (str(twilioVerify, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.message_service_sid", + ); + if (str(twilioVerify, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); + } else if (messagebirdEnabled) { + if (str(messagebird, "originator").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); + if (str(messagebird, "access_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); + } else if (textlocalEnabled) { + if (str(textlocal, "sender").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); + if (str(textlocal, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); + } else if (vonageEnabled) { + if (str(vonage, "from").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.from"); + if (str(vonage, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); + if (str(vonage, "api_secret").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); + } } - if (analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", - }), - ); + + // B5: external providers — D-only (`config.go:1148-1150`/`1368-1398`, never part of the + // shared validator). linkedin/slack are deprecated and deleted before validation, so they are + // never validated here. + const external = asRecord(authRawResolved["external"]); + if (external !== undefined) { + for (const name of Object.keys(external)) { + if (name === "linkedin" || name === "slack") continue; + const provider = asRecord(external[name]); + if (provider === undefined) continue; + if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; + if (str(provider, "client_id").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); + if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.secret`); + } } } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 986c8a6075..dbc2147e0f 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1466,6 +1466,28 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { + // Divergence #1 fix: this used to fall through to the generic + // "Failed reading config: Invalid db.major_version: 0." message (the same branch that + // catches an unsupported value like 16) — `legacyValidateResolvedConfig`'s dedicated `0` case + // now matches Go's `Missing required field in config: db.major_version`. + const dir = withConfig(["[db]", "major_version = 0", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: db.major_version", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("rejects db.major_version = 12 with Go's 12.x message", () => { const dir = withConfig(["[db]", "major_version = 12", ""].join("\n")); return read(dir).pipe( From 4515e13a6109fddbc911dd04765e2d7beb6a4ca5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 15:37:22 +0100 Subject: [PATCH 79/81] cleanup(cli): consolidate validation tests and add cross-caller parity check Move every pure legacyValidateResolvedConfig behavioral test (db.major_version, storage.buckets, edge_runtime.deno_version, analytics, experimental.*, auth.site_url/ captcha/passkey/hooks/mfa/smtp/third_party, functions.*, api.tls presence) out of legacy-local-config-values.unit.test.ts and into legacy-config-validate.unit.test.ts as direct legacyValidateResolvedConfig calls, since both D and L now share that single validator and no longer need per-caller duplication of this coverage. Env-override mechanics, file I/O, and ProjectConfig-schema-specific behavior stay in legacy-local-config-values.unit.test.ts unchanged. Add direct regression coverage for the db.major_version=0 fix (0c62a914) and the auth.captcha.provider decode-time enum check at the shared-validator level, a new test for L's auth.email.template content/content_path exclusivity divergence gained in 49fc64ae, and a dedicated legacy-config-validate.parity.unit.test.ts that drives both D's and L's real pipelines with equivalent misconfigurations and asserts they fail with the same Go-parity error message. Also updates legacy-config-validate.ts's stale module-header status note now that both D and L are fully wired through it. --- ...legacy-config-validate.parity.unit.test.ts | 266 +++++ .../legacy/shared/legacy-config-validate.ts | 15 +- .../legacy-config-validate.unit.test.ts | 936 +++++++++++++++++- .../legacy-local-config-values.unit.test.ts | 798 +-------------- 4 files changed, 1260 insertions(+), 755 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts new file mode 100644 index 0000000000..d62db6a815 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts @@ -0,0 +1,266 @@ +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 { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Effect, Exit, FileSystem, Path, Schema } from "effect"; + +import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +/** + * Cross-caller parity coverage: for a table of Go-parity misconfigurations, drives BOTH real + * pipelines — D (`legacyReadDbToml`, Effect/raw-TOML) and L (`legacyResolveLocalConfigValues`, + * `@supabase/config`-decoded) — and asserts they fail with the SAME shared error-message + * substring, since both now route through the single `legacyValidateResolvedConfig`. The two + * pipelines don't need byte-identical exception wrapping, just the same core Go-parity message + * text (`.toContain(...)` on both sides with the same expected string). + * + * D's harness replicates the `withConfig`/`read`/`failsWith` pattern from + * `legacy-db-config.toml-read.unit.test.ts` (file-local there, not exported — faithfully + * reproduced here rather than imported). L's harness replicates the `baseConfig`/`WORKDIR` + * pattern from `legacy-local-config-values.unit.test.ts` (same reasoning). + */ + +function withConfig(content: string) { + const dir = mkdtempSync(join(tmpdir(), "legacy-config-validate-parity-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync(join(dir, "supabase", "config.toml"), content); + return dir; +} + +const readD = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + +/** 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 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); + } + rmSync(dir, { recursive: true, force: true }); + }); +} + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-config-validate-parity-test"; + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +/** Drives L's real pipeline and asserts the failure message contains `message`. */ +function failsWithL( + overrides: Record, + message: string, + document?: Readonly>, +) { + const config = baseConfig(overrides); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(message); +} + +interface ParityScenario { + readonly name: string; + readonly toml: ReadonlyArray; + readonly overrides: Record; + readonly document?: Readonly>; + readonly message: string; +} + +const scenarios: ReadonlyArray = [ + { + name: "db.port = 0", + toml: ["[db]", "port = 0"], + overrides: { db: { port: 0 } }, + message: "Missing required field in config: db.port", + }, + { + name: "db.major_version = 0", + toml: ["[db]", "major_version = 0"], + overrides: { db: { major_version: 0 } }, + message: "Missing required field in config: db.major_version", + }, + { + name: "db.major_version unsupported (16)", + toml: ["[db]", "major_version = 16"], + overrides: { db: { major_version: 16 } }, + message: "Failed reading config: Invalid db.major_version: 16.", + }, + { + name: "storage bucket name with an invalid pattern", + toml: ['[storage.buckets."bad#name"]'], + overrides: { storage: { buckets: { "bad#name": {} } } }, + message: + "Invalid Bucket name: bad#name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed.", + }, + { + name: "function slug with an invalid pattern", + toml: ["[functions.123]"], + overrides: { functions: { "123": {} } }, + message: + "Invalid Function name: 123. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens.", + }, + { + name: "edge_runtime.deno_version = 0", + toml: ["[edge_runtime]", "deno_version = 0"], + overrides: { edge_runtime: { deno_version: 0 } }, + message: "Missing required field in config: edge_runtime.deno_version", + }, + { + name: "edge_runtime.deno_version unsupported (3)", + toml: ["[edge_runtime]", "deno_version = 3"], + overrides: { edge_runtime: { deno_version: 3 } }, + message: "Failed reading config: Invalid edge_runtime.deno_version: 3.", + }, + { + name: "auth.site_url empty with auth enabled", + toml: ["[auth]", "enabled = true", 'site_url = ""'], + overrides: { auth: { enabled: true, site_url: "" } }, + message: "Missing required field in config: auth.site_url", + }, + { + name: "auth.captcha enabled without a provider", + toml: ["[auth.captcha]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true }, + }, + }, + message: "Missing required field in config: auth.captcha.provider", + }, + { + name: "auth.captcha enabled with a provider but no secret", + toml: ["[auth.captcha]", "enabled = true", 'provider = "hcaptcha"'], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true, provider: "hcaptcha" }, + }, + }, + message: "Missing required field in config: auth.captcha.secret", + }, + { + name: "auth.hook.* with a badly-formatted secret", + toml: [ + "[auth.hook.custom_access_token]", + "enabled = true", + 'uri = "https://example.test/hook"', + 'secrets = "not-a-valid-secret"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + }, + }, + }, + // D's assertion goes through `JSON.stringify(exit.cause)`, which backslash-escapes the + // message's embedded double quotes — trim the substring to the quote-free prefix, same + // convention D's own suite uses for this message. + message: "auth.hook.custom_access_token.secrets must be formatted as", + }, + { + name: "auth.mfa.* enroll_enabled without verify_enabled", + toml: ["[auth.mfa.totp]", "enroll_enabled = true", "verify_enabled = false"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + mfa: { totp: { enroll_enabled: true, verify_enabled: false } }, + }, + }, + message: "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + }, + { + name: "auth.third_party.* enabled without its required field", + toml: ["[auth.third_party.firebase]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { firebase: { enabled: true } }, + }, + }, + message: "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + }, + { + name: "auth.third_party.* more than one provider enabled", + toml: [ + "[auth.third_party.firebase]", + "enabled = true", + 'project_id = "proj"', + "[auth.third_party.auth0]", + "enabled = true", + 'tenant = "tenant"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { + firebase: { enabled: true, project_id: "proj" }, + auth0: { enabled: true, tenant: "tenant" }, + }, + }, + }, + message: "Invalid config: Only one third_party provider allowed to be enabled at a time.", + }, + { + name: "auth.email.smtp present table missing a required field", + // Both pipelines read every smtp field straight off the raw TOML/document rather than a + // schema-decoded, always-defaulted value (Go's presence-based `enabled` default, + // config.go:743-748) — L needs the raw `document` (5th param) for this, matching D's raw + // smol-toml document. + toml: ["[auth.email.smtp]", 'user = "u"'], + overrides: { auth: { enabled: true, site_url: "http://localhost:3000" } }, + document: { auth: { email: { smtp: { user: "u" } } } }, + message: "Missing required field in config: auth.email.smtp.host", + }, + { + name: "experimental.pgdelta.format_options invalid JSON", + toml: ["[experimental.pgdelta]", 'format_options = "{not json"'], + overrides: { experimental: { pgdelta: { format_options: "{not json" } } }, + message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + }, +]; + +// Explicitly SKIPPED (only one caller runs the branch, or the branch isn't exercised the same +// way by both — see the module header in `legacy-config-validate.ts` for the full explicitly +// out-of-scope list): +// - `remotes[*].project_id`, `auth.sms`, `auth.external` — D-only, never part of the shared +// validator (`LegacyConfigValidationInput` has no fields for these at all). +// - `api.tls`, `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. +// - `experimental.webhooks` — L reads webhooks presence from a raw `document` that D's +// `legacyReadDbToml` doesn't thread through `legacyValidateResolvedConfig` at all (D has no +// `experimental` presence-based input field for this — verified: `legacy-db-config.toml-read.ts` +// never sets `experimental.webhooksPresent`/`webhooksEnabled` on its `LegacyExperimentalInput`), +// so this branch is D-unreachable and skipped here too. +describe("legacyValidateResolvedConfig cross-caller parity (D vs L)", () => { + for (const scenario of scenarios) { + 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); + }), + ); + } +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index 79f7a0acad..e7d6fd93a0 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -19,12 +19,15 @@ import { legacyGoUrlParse } from "./legacy-storage-url.ts"; * * ## Status of this commit * - * {@link legacyValidateResolvedConfig} is now IMPLEMENTED and wired into L - * (`legacy-local-config-values.ts`'s `legacyResolveLocalConfigValues`) — see that file for how - * it builds a {@link LegacyConfigValidationInput} from a decoded `ProjectConfig` + raw - * `document`. D (`legacy-db-config.toml-read.ts`) still runs its own inline copy of this same - * logic; wiring D through this module (and fixing D's `db.major_version === 0` divergence in - * the process) lands in a follow-up commit. + * {@link legacyValidateResolvedConfig} is now IMPLEMENTED and fully wired into BOTH callers: L + * (`legacy-local-config-values.ts`'s `legacyResolveLocalConfigValues`) and D + * (`legacy-db-config.toml-read.ts`'s `legacyReadDbToml`) each build a + * {@link LegacyConfigValidationInput} from their own decoded config (a `ProjectConfig` + raw + * `document` for L, a raw smol-toml document + `EnvLookup` for D) and call this function once, + * at the correct Go position. Wiring D through this module also fixed D's `db.major_version + * === 0` divergence (D used to fall through to the generic invalid-value message; it now + * throws the same "Missing required field in config: db.major_version" as Go and as L already + * did). * * ## Full eventual scope: every `Config.Validate` branch this module owns * diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts index 57d02a53fe..f5b4635620 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts @@ -6,13 +6,18 @@ import { LEGACY_FUNCTION_SLUG_PATTERN, LEGACY_HOOK_SECRET_PATTERN, LEGACY_PROJECT_REF_PATTERN, + type LegacyAuthInput, + type LegacyConfigValidationInput, legacyParseGoBool, + legacyValidateResolvedConfig, } from "./legacy-config-validate.ts"; -// Starter suite for the symbols relocated from `legacy-db-config.toml-read.ts` in this -// commit. The bulk of `Config.Validate` behavioral coverage (the eventual -// `legacyValidateResolvedConfig` entry point) moves here in a later commit — see the -// module header in `legacy-config-validate.ts`. +// Starter suite for the symbols relocated from `legacy-db-config.toml-read.ts` in an earlier +// commit (see the module header in `legacy-config-validate.ts`). The bulk of `Config.Validate` +// behavioral coverage — direct calls to `legacyValidateResolvedConfig`, covering every branch +// this module owns regardless of which caller (D or L) exercises it — lives further down this +// file; it was consolidated here from `legacy-local-config-values.unit.test.ts`, where it used +// to be exercised only indirectly through `legacyResolveLocalConfigValues`. describe("legacyParseGoBool", () => { it("accepts Go's strconv.ParseBool true forms", () => { @@ -90,3 +95,926 @@ describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { expect(LEGACY_CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); }); }); + +/** + * A trivially-passing full input. Every test below spreads/overrides only the field(s) its + * check cares about, matching the fixture-building style of `legacy-local-config-values.unit + * .test.ts`'s own `baseConfig()` helper. + */ +function minimalInput( + overrides: Partial = {}, +): LegacyConfigValidationInput { + return { + db: { port: 5432, majorVersion: 17 }, + storageBucketNames: [], + functionSlugs: [], + edgeRuntimeDenoVersion: 2, + analytics: { + enabled: false, + backend: undefined, + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + experimental: { pgdeltaFormatOptions: "" }, + ...overrides, + }; +} + +/** A trivially-passing `[auth]` section — auth enabled, nothing else configured. */ +function minimalAuthInput(overrides: Partial = {}): LegacyAuthInput { + return { + siteUrl: "http://localhost:3000", + hooks: [], + mfa: [], + thirdParty: [], + ...overrides, + }; +} + +// Moved from `legacy-local-config-values.unit.test.ts`: these describe blocks exercise checks +// that now live entirely inside `legacyValidateResolvedConfig` and can be phrased as direct +// calls with a hand-built `LegacyConfigValidationInput` — no `ProjectConfig`/schema decode, no +// env-override machinery, no file I/O, no `document` threading. Everything that still needs +// one of those (value derivation, env-override mechanics, the 3 I/O checks' actual file reads) +// stays in `legacy-local-config-values.unit.test.ts`. +describe("legacyValidateResolvedConfig", () => { + // config.go:1034-1062 — db.major_version switch. The env-override + // (SUPABASE_DB_MAJOR_VERSION) variants stay in legacy-local-config-values.unit.test.ts. + describe("db.major_version", () => { + it("rejects a configured major_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 0 } })), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 12 } })), + ).toThrow("Postgres version 12.x is unsupported."); + }); + + it.each([13, 14, 15, 17])("accepts the supported major_version %d", (majorVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion } })), + ).not.toThrow(); + }); + + it("rejects an unsupported major_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 16 } })), + ).toThrow("Failed reading config: Invalid db.major_version: 16."); + }); + }); + + // config.go:1064-1068, pattern @ 1549-1554 — unconditional, no storage.enabled-style gate. + describe("storage.buckets", () => { + it("rejects a bucket name Go's ValidateBucketName refuses", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["bad/name"] })), + ).toThrow("Invalid Bucket name: bad/name."); + }); + + it("does not throw for a valid bucket name", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["avatars.public"] })), + ).not.toThrow(); + }); + + it("does not throw when no buckets are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled (there is no such field on LegacyConfigValidationInput at all). The + // env-override (SUPABASE_EDGE_RUNTIME_DENO_VERSION) variants stay in + // legacy-local-config-values.unit.test.ts. + describe("edge_runtime.deno_version", () => { + it("rejects a configured deno_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + + it.each([1, 2])("accepts the supported deno_version %d", (denoVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: denoVersion })), + ).not.toThrow(); + }); + + it("rejects an unsupported deno_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 3 })), + ).toThrow("Failed reading config: Invalid edge_runtime.deno_version: 3."); + }); + + it("rejects an invalid deno_version even when edge_runtime is disabled", () => { + // There is no `edgeRuntime.enabled`-style gate on `LegacyConfigValidationInput` at + // all — this is identical to the "rejects a configured deno_version of 0" case above, + // which is itself the point: Go never gates this check on edge_runtime.enabled. + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + }); + + // config.go:1174-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". The + // env-override (SUPABASE_ANALYTICS_*) variants stay in legacy-local-config-values.unit.test.ts. + describe("analytics (BigQuery backend required fields)", () => { + it("rejects an enabled bigquery backend without gcp_project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_id"); + }); + + it("rejects an enabled bigquery backend without gcp_project_number", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_number"); + }); + + it("rejects an enabled bigquery backend without gcp_jwt_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "", + }, + }), + ), + ).toThrow( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + }); + + it("does not throw when an enabled bigquery backend has all three GCP fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "gcp.json", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw for the postgres backend, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "postgres", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when analytics is disabled, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: false, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + }); + + // config.go:1846-1854 — experimental.validate(), unconditional, internally gated. + describe("experimental.*", () => { + it("rejects a present [experimental.webhooks] section with enabled omitted", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { webhooksPresent: true, pgdeltaFormatOptions: "" } }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("rejects a present [experimental.webhooks] section with enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: false, + pgdeltaFormatOptions: "", + }, + }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("does not throw when [experimental.webhooks] enabled = true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: true, + pgdeltaFormatOptions: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [experimental.webhooks] is absent entirely", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects invalid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: "{not json" } }), + ), + ).toThrow("Invalid config for experimental.pgdelta.format_options: must be valid JSON"); + }); + + it("does not throw for valid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: '{"keywordCase":"upper"}' } }), + ), + ).not.toThrow(); + }); + + it("does not throw when experimental.pgdelta.format_options is unset", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ experimental: { pgdeltaFormatOptions: "" } })), + ).not.toThrow(); + }); + }); + + // config.go:1088-1090 — auth.site_url, checked first inside `if c.Auth.Enabled`. An absent + // `auth` section on `LegacyConfigValidationInput` IS "auth disabled" from this function's + // perspective. The SUPABASE_AUTH_ENABLED/SUPABASE_AUTH_SITE_URL env-override variants stay in + // legacy-local-config-values.unit.test.ts. + describe("auth.site_url", () => { + it("rejects an explicit empty site_url when auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ siteUrl: "" }) })), + ).toThrow("Missing required field in config: auth.site_url"); + }); + + it("does not throw when site_url is set and auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ siteUrl: "http://localhost:3000" }) }), + ), + ).not.toThrow(); + }); + + it("does not throw an explicit empty site_url when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha, checked right after auth.site_url. + describe("auth.captcha", () => { + it("rejects an enabled captcha without a provider", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: undefined, secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); + + it("rejects an enabled captcha with a provider but no secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.secret"); + }); + + it("does not throw when an enabled captcha has both provider and secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: "shh" }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when captcha is disabled, however incomplete", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: undefined, secret: undefined }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enabled captcha without provider/secret when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1117-1134 — auth.passkey/auth.webauthn, right after the (caller-side) signing-keys + // read. + describe("auth.passkey / auth.webauthn", () => { + it("rejects passkey.enabled without an [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: false, rpId: undefined, rpOrigins: undefined }, + }), + }), + ), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: undefined, + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_origins", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: true, rpId: "localhost", rpOrigins: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + }); + + it("does not throw when passkey.enabled has a complete [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: "localhost", + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when passkey is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ passkey: undefined }) }), + ), + ).not.toThrow(); + }); + + it("does not throw when auth carries no passkey data at all", () => { + // Distinct from the previous test only in the original (L-level) caller's derivation — + // "webauthn absent from the document" vs. "no document was threaded through at all". + // Both collapse to `passkey: undefined` at this shared, direct-call layer. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled passkey without webauthn when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + describe("auth.email.smtp", () => { + it("rejects a present [auth.email.smtp] table with no fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { enabled: true, host: "", port: 0, user: "", pass: "", adminEmail: "" }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("rejects a present [auth.email.smtp] table missing port/user/pass/admin_email", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.port"); + }); + + it("does not throw when [auth.email.smtp] explicitly sets enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: false, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is a complete table", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 587, + user: "user", + pass: "pass", + adminEmail: "admin@example.com", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ smtp: undefined }) })), + ).not.toThrow(); + }); + + it("does not throw when auth carries no smtp data at all", () => { + // See the equivalent passkey note above — both collapse to `smtp: undefined` here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw a present but incomplete [auth.email.smtp] table when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filters to enabled-only. + describe("auth.hook.*", () => { + it("rejects an enabled hook without a uri", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "", secrets: "" }], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.uri"); + }); + + it("rejects an http(s) hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { type: "custom_access_token", uri: "https://example.test/hook", secrets: "" }, + ], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.secrets"); + }); + + it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + ], + }), + }), + ), + ).toThrow( + 'auth.hook.custom_access_token.secrets must be formatted as "v1,whsec_"', + ); + }); + + it("does not throw for a valid http(s) hook secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a pg-functions hook uri with secrets set", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.secrets is unsupported for pg-functions URI"); + }); + + it("does not throw for a pg-functions hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: "", + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a hook uri with an unsupported scheme", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "ftp://example.test/hook", secrets: "" }], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + }); + + it("does not throw for a disabled hook, however incomplete", () => { + // The caller pre-filters to enabled-only hooks — a disabled hook is simply absent from + // `hooks`, matching an empty array here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ hooks: [] }) })), + ).not.toThrow(); + }); + + it("does not throw an enabled hook without a uri when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, fixed totp/phone/web_authn order. + describe("auth.mfa.*", () => { + it.each([ + ["totp", "auth.mfa.totp.enroll_enabled requires verify_enabled"], + ["phone", "auth.mfa.phone.enroll_enabled requires verify_enabled"], + ["web_authn", "auth.mfa.web_authn.enroll_enabled requires verify_enabled"], + ] as const)("rejects %s enroll_enabled without verify_enabled", (label, message) => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label, enrollEnabled: true, verifyEnabled: false }], + }), + }), + ), + ).toThrow(message); + }); + + it("does not throw when enroll_enabled and verify_enabled are both true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label: "totp", enrollEnabled: true, verifyEnabled: true }], + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enroll_enabled MFA factor without verify_enabled when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, fixed provider order, caller + // pre-filters to enabled-only. + describe("auth.third_party.*", () => { + it("rejects firebase enabled without a project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "firebase", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.firebase is enabled but without a project_id."); + }); + + it("rejects auth0 enabled without a tenant", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "auth0", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); + }); + + it("rejects aws_cognito enabled without a user_pool_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "cognito", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id."); + }); + + it("rejects aws_cognito enabled with a user_pool_id but no user_pool_region", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "cognito", requiredField: "pool-1", cognitoUserPoolRegion: undefined }, + ], + }), + }), + ), + ).toThrow( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + }); + + it("rejects clerk enabled without a domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "clerk", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk is enabled but without a domain."); + }); + + it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "not-a-clerk-domain" }], + }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk has invalid domain"); + }); + + it("does not throw for a valid clerk.example.com domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "clerk.example.com" }], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects workos enabled without an issuer_url", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "workos", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.workos is enabled but without a issuer_url."); + }); + + it("rejects more than one third_party provider enabled at once", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "firebase", requiredField: "proj" }, + { provider: "auth0", requiredField: "tenant" }, + ], + }), + }), + ), + ).toThrow("Invalid config: Only one third_party provider allowed to be enabled at a time."); + }); + + it("does not throw when no third_party provider is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled third_party provider missing its required field when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + describe("functions.*", () => { + it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + + it("does not throw for a valid function slug", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["hello-world_v2"] })), + ).not.toThrow(); + }); + + it("does not throw when no functions are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects an invalid function slug even when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + }); + + // config.go:1006-1027 — only the "exactly one of cert/key set" presence rule; the actual + // file reads and the disabled-skip/env-override tests stay in + // legacy-local-config-values.unit.test.ts. + describe("api.tls", () => { + it("rejects cert_path set without key_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: "cert.pem", keyPath: undefined }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.key_path"); + }); + + it("rejects key_path set without cert_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: undefined, keyPath: "key.pem" }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.cert_path"); + }); + }); + + // Direct-shared-level regression/divergence coverage (Part C) — behavior that's either new + // (the D fix) or only meaningfully testable at this exact layer (the captcha enum), not a + // move from either caller's own suite. + describe("Config.Validate divergence regression coverage", () => { + it("throws the Go-parity missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { + // D used to fall through to the generic "Invalid db.major_version: 0" message; both D + // and L now go through this exact branch, so pin it directly here too, not just via + // D's/L's own suites. + expect(() => + legacyValidateResolvedConfig({ ...minimalInput(), db: { port: 5432, majorVersion: 0 } }), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("throws Go's decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { + // This scenario is only meaningful at this direct shared-validator level: it's + // unreachable through L's real ProjectConfig-typed flow — `@supabase/config`'s schema + // (packages/config/src/auth/captcha.ts, stringEnum(["hcaptcha", "turnstile"])) already + // narrows `provider` to "hcaptcha" | "turnstile" | undefined before it ever reaches + // `legacyResolveLocalConfigValues`, so an invalid provider value would fail schema + // decoding first, on a completely different code path. D's real TOML flow CAN reach + // this branch (an untyped raw string) — D's own suite + // (`legacy-db-config.toml-read.unit.test.ts`) covers that separately. This test's job is + // only to pin the shared function's own behavior directly. + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: "not-a-real-provider", secret: undefined }, + }), + }), + ), + ).toThrow( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 3983ec997d..d1b0afe7c2 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -525,36 +525,13 @@ describe("legacyResolveLocalConfigValues", () => { }); describe("db.major_version (required field in config)", () => { + // The pure 0/12/13-17/generic-invalid assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. afterEach(() => { delete process.env["SUPABASE_DB_MAJOR_VERSION"]; }); - it("rejects a configured major_version of 0", () => { - const config = baseConfig({ db: { major_version: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: db.major_version", - ); - }); - - it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { - const config = baseConfig({ db: { major_version: 12 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Postgres version 12.x is unsupported.", - ); - }); - - it.each([13, 14, 15, 17])("accepts the supported major_version %d", (majorVersion) => { - const config = baseConfig({ db: { major_version: majorVersion } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an unsupported major_version with the generic invalid-value message", () => { - const config = baseConfig({ db: { major_version: 16 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid db.major_version: 16.", - ); - }); - it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; const config = baseConfig({ db: { major_version: 17 } }); @@ -587,52 +564,21 @@ describe("legacyResolveLocalConfigValues", () => { // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] // key right after db.major_version, unconditionally — there is no // storage.enabled-style gate (pkg/config/config.go:1063-1068). - describe("storage.buckets (bucket-name validation)", () => { - it("rejects a bucket name Go's ValidateBucketName refuses", () => { - const config = baseConfig({ storage: { buckets: { "bad/name": {} } } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid Bucket name: bad/name.", - ); - }); - - it("does not throw for a valid bucket name", () => { - const config = baseConfig({ storage: { buckets: { "avatars.public": {} } } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw when no buckets are configured", () => { - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); + // + // Moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — this section has no L-specific derivation or env-override mechanics of its own. // Go's Config.Validate rejects an invalid edge_runtime.deno_version // unconditionally — NOT gated on edge_runtime.enabled // (pkg/config/config.go:1164-1173). describe("edge_runtime.deno_version (required field in config)", () => { + // The pure 0/1/2/generic-invalid/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. afterEach(() => { delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; }); - it("rejects a configured deno_version of 0", () => { - const config = baseConfig({ edge_runtime: { deno_version: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: edge_runtime.deno_version", - ); - }); - - it.each([1, 2])("accepts the supported deno_version %d", (denoVersion) => { - const config = baseConfig({ edge_runtime: { deno_version: denoVersion } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an unsupported deno_version with the generic invalid-value message", () => { - const config = baseConfig({ edge_runtime: { deno_version: 3 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: 3.", - ); - }); - it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); @@ -662,13 +608,6 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ edge_runtime: { deno_version: 2 } }); expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - - it("rejects an invalid deno_version even when edge_runtime is disabled", () => { - const config = baseConfig({ edge_runtime: { enabled: false, deno_version: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: edge_runtime.deno_version", - ); - }); }); describe("analytics (BigQuery backend required fields)", () => { @@ -676,6 +615,10 @@ describe("legacyResolveLocalConfigValues", () => { // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP // fields are required, checked in that order. + // + // The pure required-field/complete/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. afterEach(() => { delete process.env["SUPABASE_ANALYTICS_ENABLED"]; delete process.env["SUPABASE_ANALYTICS_BACKEND"]; @@ -684,59 +627,6 @@ describe("legacyResolveLocalConfigValues", () => { delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; }); - it("rejects an enabled bigquery backend without gcp_project_id", () => { - const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: analytics.gcp_project_id", - ); - }); - - it("rejects an enabled bigquery backend without gcp_project_number", () => { - const config = baseConfig({ - analytics: { enabled: true, backend: "bigquery", gcp_project_id: "proj" }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: analytics.gcp_project_number", - ); - }); - - it("rejects an enabled bigquery backend without gcp_jwt_path", () => { - const config = baseConfig({ - analytics: { - enabled: true, - backend: "bigquery", - gcp_project_id: "proj", - gcp_project_number: "123", - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", - ); - }); - - it("does not throw when an enabled bigquery backend has all three GCP fields", () => { - const config = baseConfig({ - analytics: { - enabled: true, - backend: "bigquery", - gcp_project_id: "proj", - gcp_project_number: "123", - gcp_jwt_path: "gcp.json", - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw for the postgres backend, however incomplete the GCP fields are", () => { - const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw when analytics is disabled, however incomplete the GCP fields are", () => { - const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); @@ -783,50 +673,12 @@ describe("legacyResolveLocalConfigValues", () => { // called right after the analytics/bigquery block and right before // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. // - // The webhooks check hinges on whether `[experimental.webhooks]` is - // PRESENT in config.toml, not the decoded `enabled` value — the shared - // schema decode-fills `experimental.webhooks = { enabled: false }` even - // when the section is entirely absent, so these tests pass the raw - // `document` (the 5th param) to simulate what config.toml actually - // contained, exactly like `LoadedProjectConfig.document` would. - it("rejects a present [experimental.webhooks] section with enabled omitted", () => { - const config = baseConfig({ experimental: { webhooks: {} } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - experimental: { webhooks: {} }, - }), - ).toThrow( - "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", - ); - }); - - it("rejects a present [experimental.webhooks] section with enabled = false", () => { - const config = baseConfig({ experimental: { webhooks: { enabled: false } } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - experimental: { webhooks: { enabled: false } }, - }), - ).toThrow( - "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", - ); - }); - - it("does not throw when [experimental.webhooks] enabled = true", () => { - const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - experimental: { webhooks: { enabled: true } }, - }), - ).not.toThrow(); - }); - - it("does not throw when [experimental.webhooks] is absent entirely", () => { - const config = baseConfig(); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, {}), - ).not.toThrow(); - }); - + // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks + // moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of + // deriving them from a raw `document`) — only this document-THREADING-specific case stays + // here, since it exercises this function's own "no document provided" fallback rather than + // a check `legacyValidateResolvedConfig` itself owns. it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { // No `document` (5th param) at all — e.g. a caller that hasn't threaded // `LoadedProjectConfig.document` through yet. The presence-only check @@ -836,25 +688,6 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ experimental: { webhooks: {} } }); expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - - it("rejects invalid JSON in experimental.pgdelta.format_options", () => { - const config = baseConfig({ experimental: { pgdelta: { format_options: "{not json" } } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - ); - }); - - it("does not throw for valid JSON in experimental.pgdelta.format_options", () => { - const config = baseConfig({ - experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw when experimental.pgdelta.format_options is unset", () => { - const config = baseConfig({ experimental: { pgdelta: {} } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); }); describe("SUPABASE_API_TLS_ENABLED env override", () => { @@ -1060,26 +893,9 @@ describe("legacyResolveLocalConfigValues", () => { }); describe("auth.site_url (required field in config)", () => { - it("rejects an explicit empty site_url when auth is enabled", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.site_url", - ); - }); - - it("does not throw when site_url is set and auth is enabled", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - // Go's `Validate` nests this check inside `if c.Auth.Enabled` - // (`pkg/config/config.go:1086-1090`) — a disabled auth section never - // requires site_url, however empty it is. - it("does not throw an explicit empty site_url when auth is disabled", () => { - const config = baseConfig({ auth: { enabled: false, site_url: "" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - + // The pure empty/set/disabled assertions moved to `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / + // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { afterEach(() => { delete process.env["SUPABASE_AUTH_ENABLED"]; @@ -1108,377 +924,20 @@ describe("legacyResolveLocalConfigValues", () => { }); }); - describe("auth.captcha (required fields when enabled)", () => { - // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, - // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`). - it("rejects an enabled captcha without a provider", () => { - const config = baseConfig({ - auth: { enabled: true, site_url: "http://localhost:3000", captcha: { enabled: true } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.captcha.provider", - ); - }); - - it("rejects an enabled captcha with a provider but no secret", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - captcha: { enabled: true, provider: "hcaptcha" }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.captcha.secret", - ); - }); - - it("does not throw when an enabled captcha has both provider and secret", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - captcha: { enabled: true, provider: "hcaptcha", secret: "shh" }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw when captcha is disabled, however incomplete", () => { - const config = baseConfig({ - auth: { enabled: true, site_url: "http://localhost:3000", captcha: { enabled: false } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - // A disabled auth section never requires captcha fields, however - // incomplete the captcha config is. - it("does not throw an enabled captcha without provider/secret when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, captcha: { enabled: true } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.passkey / auth.webauthn (WebAuthn requirement when passkey enabled)", () => { - // Go's Config.Validate rejects [auth.passkey] enabled = true without a - // complete [auth.webauthn] (pkg/config/config.go:1117-1129), right after - // the signing-keys read and before Auth.Hook.validate(). @supabase/config's - // schema has no passkey/webauthn fields at all, so this check only runs - // when the raw `document` (5th param) is provided. - it("rejects passkey.enabled without an [auth.webauthn] section", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { passkey: { enabled: true } }, - }), - ).toThrow( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - }); - - it("rejects passkey.enabled with [auth.webauthn] missing rp_id", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { passkey: { enabled: true }, webauthn: { rp_origins: ["http://localhost:3000"] } }, - }), - ).toThrow("Missing required field in config: auth.webauthn.rp_id"); - }); - - it("rejects passkey.enabled with [auth.webauthn] missing rp_origins", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost" } }, - }), - ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); - }); - - it("does not throw when passkey.enabled has a complete [auth.webauthn] section", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { - passkey: { enabled: true }, - webauthn: { rp_id: "localhost", rp_origins: ["http://localhost:3000"] }, - }, - }), - ).not.toThrow(); - }); - - it("does not throw when passkey is absent from the document", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { auth: {} }), - ).not.toThrow(); - }); - - it("does not throw passkey.enabled without webauthn when no document is provided", () => { - // No `document` (5th param) at all — the same skip-rather-than-guess - // behavior every pre-existing call site/test in this file relies on. - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw an enabled passkey without webauthn when auth is disabled", () => { - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { passkey: { enabled: true } }, - }), - ).not.toThrow(); - }); - }); - - describe("auth.email.smtp (present-table-implies-enabled)", () => { - // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` - // table is present but omits `enabled` (`pkg/config/config.go:743-748`), a - // presence-based default set on the raw viper map BEFORE the struct - // decodes — not a struct-tag default (Go's own zero-value is `false`). - // `@supabase/config`'s schema always decodes `smtp.enabled` to `false` when - // the key is absent, erasing that presence signal, so this check only runs - // when the raw `document` (5th param) is provided — same shape as the - // passkey/webauthn checks above. - it("rejects a present [auth.email.smtp] table with no fields", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { email: { smtp: {} } }, - }), - ).toThrow("Missing required field in config: auth.email.smtp.host"); - }); - - it("rejects a present [auth.email.smtp] table missing port/user/pass/admin_email", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { email: { smtp: { host: "smtp.example.com" } } }, - }), - ).toThrow("Missing required field in config: auth.email.smtp.port"); - }); - - it("does not throw when [auth.email.smtp] explicitly sets enabled = false", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { email: { smtp: { enabled: false, host: "smtp.example.com" } } }, - }), - ).not.toThrow(); - }); - - it("does not throw when [auth.email.smtp] is a complete table", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { - email: { - smtp: { - host: "smtp.example.com", - port: 587, - user: "user", - pass: "pass", - admin_email: "admin@example.com", - }, - }, - }, - }), - ).not.toThrow(); - }); - - it("does not throw when [auth.email.smtp] is absent from the document", () => { - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { auth: {} }), - ).not.toThrow(); - }); - - it("does not throw a present [auth.email.smtp] table when no document is provided", () => { - // No `document` (5th param) at all — the same skip-rather-than-guess - // behavior every pre-existing call site/test in this file relies on. - const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw a present but incomplete [auth.email.smtp] table when auth is disabled", () => { - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, { - auth: { email: { smtp: { host: "smtp.example.com" } } }, - }), - ).not.toThrow(); - }); - }); - - describe("auth.hook.* (URI/secret validation when enabled)", () => { - // Go's `Config.Validate` runs `Auth.Hook.validate()` right after signing - // keys/passkey validation, still inside `if c.Auth.Enabled` - // (`pkg/config/config.go:1136-1139`). - it("rejects an enabled hook without a uri", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { custom_access_token: { enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.hook.custom_access_token.uri", - ); - }); - - it("rejects an http(s) hook uri without secrets", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { - custom_access_token: { enabled: true, uri: "https://example.test/hook" }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.hook.custom_access_token.secrets", - ); - }); - - it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { - custom_access_token: { - enabled: true, - uri: "https://example.test/hook", - secrets: "not-a-valid-secret", - }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'auth.hook.custom_access_token.secrets must be formatted as "v1,whsec_"', - ); - }); - - it("does not throw for a valid http(s) hook secret", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { - custom_access_token: { - enabled: true, - uri: "https://example.test/hook", - secrets: `v1,whsec_${"a".repeat(32)}`, - }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a pg-functions hook uri with secrets set", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { - custom_access_token: { - enabled: true, - uri: "pg-functions://postgres/public/hook", - secrets: `v1,whsec_${"a".repeat(32)}`, - }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "auth.hook.custom_access_token.secrets is unsupported for pg-functions URI", - ); - }); - - it("does not throw for a pg-functions hook uri without secrets", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { - custom_access_token: { enabled: true, uri: "pg-functions://postgres/public/hook" }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a hook uri with an unsupported scheme", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { custom_access_token: { enabled: true, uri: "ftp://example.test/hook" } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI", - ); - }); - - it("does not throw for a disabled hook, however incomplete", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - hook: { custom_access_token: { enabled: false } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw an enabled hook without a uri when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, hook: { custom_access_token: { enabled: true } } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.mfa.* (enroll_enabled requires verify_enabled)", () => { - // Go's `(m *mfa) validate()` (`pkg/config/config.go:1523-1534`), called right - // after `Auth.Hook.validate()`, still inside `if c.Auth.Enabled`. - it.each([ - ["totp", "auth.mfa.totp.enroll_enabled requires verify_enabled"], - ["phone", "auth.mfa.phone.enroll_enabled requires verify_enabled"], - ["web_authn", "auth.mfa.web_authn.enroll_enabled requires verify_enabled"], - ] as const)("rejects %s enroll_enabled without verify_enabled", (factor, message) => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - mfa: { [factor]: { enroll_enabled: true, verify_enabled: false } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow(message); - }); + // auth.captcha (required fields when enabled) moved entirely to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — L + // reads `config.auth.captcha` directly with no env-override mechanics of its own. - it("does not throw when enroll_enabled and verify_enabled are both true", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - mfa: { totp: { enroll_enabled: true, verify_enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + // auth.passkey / auth.webauthn (WebAuthn requirement when passkey enabled) and + // auth.email.smtp (present-table-implies-enabled) moved entirely to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls, + // setting `auth.passkey`/`auth.smtp` directly instead of deriving them from a raw + // `document`) — both are pure, document-presence-based checks with no env-override variant. - it("does not throw an enroll_enabled MFA factor without verify_enabled when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, mfa: { totp: { enroll_enabled: true, verify_enabled: false } } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); + // auth.hook.* (URI/secret validation when enabled) and auth.mfa.* (enroll_enabled requires + // verify_enabled) moved entirely to `legacy-config-validate.unit.test.ts` (direct + // `legacyValidateResolvedConfig` calls) — L pre-filters to enabled-only hooks/derives mfa + // directly off `config.auth.*` with no env-override mechanics of its own for these checks. describe("auth.email.template/notification (content_path validation)", () => { // Go's `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`), @@ -1585,172 +1044,33 @@ describe("legacyResolveLocalConfigValues", () => { legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), ).not.toThrow(); }); - }); - - describe("auth.third_party.* (thirdParty.validate())", () => { - // Go's `(tpa *thirdParty) validate()` (`pkg/config/config.go:1635-1683`), called - // right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. - it("rejects firebase enabled without a project_id", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { firebase: { enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); - - it("rejects auth0 enabled without a tenant", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { auth0: { enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", - ); - }); - - it("rejects aws_cognito enabled without a user_pool_id", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { aws_cognito: { enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - }); - - it("rejects aws_cognito enabled with a user_pool_id but no user_pool_region", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { aws_cognito: { enabled: true, user_pool_id: "pool-1" } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); - }); - it("rejects clerk enabled without a domain", () => { + // Divergence #2 (see `legacy-config-validate.ts`'s port-plan notes): Go's asymmetric + // content-vs-content_path exclusivity (`config.go:1293-1313`) — a raw `content` key present + // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no + // `content` field to see, so this only fires when the raw `document` (5th param) carries it. + it("rejects a template content key present without content_path", () => { const config = baseConfig({ auth: { enabled: true, site_url: "http://localhost:3000", - third_party: { clerk: { enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.clerk is enabled but without a domain.", - ); - }); - - it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { clerk: { enabled: true, domain: "not-a-clerk-domain" } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.clerk has invalid domain", - ); - }); - - it("does not throw for a valid clerk.example.com domain", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects workos enabled without an issuer_url", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { workos: { enabled: true } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", - ); - }); - - it("rejects more than one third_party provider enabled at once", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - third_party: { - firebase: { enabled: true, project_id: "proj" }, - auth0: { enabled: true, tenant: "tenant" }, - }, + email: { template: { invite: {} } }, }, }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", + expect(() => + legacyResolveLocalConfigValues(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", ); }); - - it("does not throw when no third_party provider is enabled", () => { - const config = baseConfig({ - auth: { enabled: true, site_url: "http://localhost:3000" }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw an enabled third_party provider missing its required field when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, third_party: { firebase: { enabled: true } } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); }); - // Go's Config.Validate runs ValidateFunctionSlug over every [functions.*] key - // right after the auth block/generateAPIKeys, unconditionally — NOT gated on - // auth.enabled (pkg/config/config.go:1155-1163). - describe("functions.* (function-slug validation)", () => { - it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { - const config = baseConfig({ functions: { "1bad": {} } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid Function name: 1bad.", - ); - }); - - it("does not throw for a valid function slug", () => { - const config = baseConfig({ functions: { "hello-world_v2": {} } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw when no functions are configured", () => { - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an invalid function slug even when auth is disabled", () => { - const config = baseConfig({ auth: { enabled: false }, functions: { "1bad": {} } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid Function name: 1bad.", - ); - }); - }); + // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) + // moved entirely to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — L pre-filters to enabled-only third_party providers and derives function slugs + // directly off `config.functions` with no env-override mechanics of its own for these checks. describe("api.tls (cert/key validation)", () => { const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); @@ -1770,21 +1090,9 @@ describe("legacyResolveLocalConfigValues", () => { ).not.toThrow(); }); - it("rejects cert_path set without key_path", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - const config = baseConfig({ api: { tls: { enabled: true, cert_path: "cert.pem" } } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Missing required field in config: api.tls.key_path", - ); - }); - - it("rejects key_path set without cert_path", () => { - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ api: { tls: { enabled: true, key_path: "key.pem" } } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Missing required field in config: api.tls.cert_path", - ); - }); + // The "exactly one of cert/key set" presence-only assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // the actual file reads below stay here, since I/O is per-caller. it("throws a Go-worded error when the configured cert file does not exist", () => { writeTlsFile(tempRoot.current, "key.pem"); From b257e200b5b2fba91e3018dbbfcbe1e17cf35c2c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 15:48:15 +0100 Subject: [PATCH 80/81] docs(cli): declare legacy-config-validate the single home for Config.Validate parity --- apps/cli/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index f16021bf07..e18d181aa9 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -237,6 +237,10 @@ Concrete examples worth watching for as more commands land: This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. +### `Config.Validate` parity has one home + +Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. + --- ## Legacy Port: Go CLI Output Parity From 2ddbeb555a0f076594e16f80e89952a5c33ad664 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 16:21:49 +0100 Subject: [PATCH 81/81] fix(cli): opt legacySeedBucketsRun into goViperCompat after seed-buckets hoist merge develop's db push/reset/start port (#5715) hoisted the seed-buckets config load out of buckets.handler.ts and into the shared legacySeedBucketsRun (also used by db reset --local), which superseded the inline goViperCompat opt-in added on this branch. Carry the opt-in over to the new call site so seed buckets and db reset --local keep Go-parity config semantics. --- apps/cli/src/legacy/shared/legacy-seed-buckets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index 382d16e6b1..0ec17002b5 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -144,8 +144,8 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // Load config.toml, passing projectRef so `[remotes.*]` overrides are merged for // --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError",