diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 3adffbee4c..dc00497f14 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -143,6 +143,7 @@ the whole reset** (not just "skip buckets"). | `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image, realtime/storage/auth migrate-job images, and the pg-delta edge-runtime catalog-export image from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, `deno_version = 1`, and historical `.temp/edge-runtime-version` pins stay on docker.io | no (ambient shell only) | | `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | | `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_API_PORT` / `SUPABASE_API_EXTERNAL_URL` / `SUPABASE_API_TLS_*` / `SUPABASE_API_ENABLED` | local-path bucket-seed step: override the matching `[api]` fields for the Storage gateway URL/TLS, same as `seed buckets` (shell or project dotenv; #6452) | no | | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Connection loss during migration apply diff --git a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index c961e7a364..5f635c72a2 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -6,14 +6,14 @@ stack is used; with `--linked` the remote project is used. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | -| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/`, an absolute path is used as-is | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | -| `/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.env*`, `/.env*` | dotenv | when no pre-resolved `yes` is passed in (the standalone command; `db reset --local` passes its own), to resolve `SUPABASE_YES` for the overwrite/prune prompts (CLI-1878) | +| Path | Format | When | +| --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | +| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/`, an absolute path is used as-is | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| `/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `/supabase/.env*`, `/.env*` | dotenv | once per run, unless the caller already resolved the map (`db reset --local` and `start` pass theirs through): resolves `SUPABASE_YES` for the overwrite/prune prompts on either target (CLI-1878) and, on local runs, the `SUPABASE_API_*` overrides for the gateway URL and TLS material (#6452) | ## Files Written @@ -26,7 +26,11 @@ stack is used; with `--linked` the remote project is used. ### Storage gateway routes (local and remote) **Local:** `api.external_url` (default `http://:54321`, where `` resolves as: -`SUPABASE_SERVICES_HOSTNAME` → TCP `DOCKER_HOST` → `127.0.0.1`). +`SUPABASE_SERVICES_HOSTNAME` → TCP `DOCKER_HOST` → `127.0.0.1`). The +`api.{enabled,external_url,port}` and `api.tls.{enabled,cert_path,key_path}` +values are resolved with their `SUPABASE_API_*` shell/dotenv overrides applied +first, so the gateway targets the same port/scheme the stack was actually +brought up on (#6452). **Remote (`--linked`):** `https://.` (default host: `supabase.co`). @@ -66,29 +70,31 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_SERVICES_HOSTNAME` | override the local services host (highest precedence) | no | -| `DOCKER_HOST` | when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | -| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch); for local runs, used as the service-role key instead of `auth.service_role_key` | no | -| `SUPABASE_AUTH_JWT_SECRET` | local runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation | no | -| `SUPABASE_YES` | auto-confirms the overwrite/prune prompts, same as `--yes`; read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | no | +| Variable | Purpose | Required? | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_SERVICES_HOSTNAME` | override the local services host (highest precedence) | no | +| `DOCKER_HOST` | when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch); for local runs, used as the service-role key instead of `auth.service_role_key` | no | +| `SUPABASE_AUTH_JWT_SECRET` | local runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation | no | +| `SUPABASE_YES` | auto-confirms the overwrite/prune prompts, same as `--yes`; read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | no | +| `SUPABASE_API_PORT`, `SUPABASE_API_EXTERNAL_URL`, `SUPABASE_API_TLS_ENABLED`, `SUPABASE_API_TLS_CERT_PATH`, `SUPABASE_API_TLS_KEY_PATH`, `SUPABASE_API_ENABLED` | local runs only: override the matching `[api]` config fields for the gateway URL derivation and TLS validation, shell env OR project dotenv files (shell wins; #6452) | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------- | -| `0` | success (including the empty-config short-circuit) | -| `1` | `supabase/config.toml` parse failure | -| `1` | `auth.jwt_secret` (or `SUPABASE_AUTH_JWT_SECRET`) set but shorter than 16 characters | -| `1` | `[storage.buckets]` entry has an invalid name (contains characters outside the allowed bucket-name pattern) | -| `1` | `api.tls.cert_path` set without `api.tls.key_path` (or vice-versa) when `api.tls.enabled = true` (local only) | -| `1` | `api.tls.cert_path` or `api.tls.key_path` points to an unreadable file (local TLS only) | -| `1` | Storage API error (non-2xx) other than vector-unavailable | -| `1` | network / connection failure to the Storage gateway | -| `1` | malformed list response (a 200 body whose shape doesn't decode) | -| `1` | unreadable `objects_path` (filesystem error during walk/upload) | -| `1` | `--project-ref` set without `--linked` (see Notes) | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (including the empty-config short-circuit, provided the local `[api]` checks pass: override decode, non-zero enabled port, TLS cert/key pairing) | +| `1` | `supabase/config.toml` parse failure | +| `1` | `auth.jwt_secret` (or `SUPABASE_AUTH_JWT_SECRET`) set but shorter than 16 characters | +| `1` | `[storage.buckets]` entry has an invalid name (contains characters outside the allowed bucket-name pattern) | +| `1` | `api.tls.cert_path` set without `api.tls.key_path` (or vice-versa) when `api.tls.enabled = true` (local only) | +| `1` | malformed `SUPABASE_API_PORT` / `SUPABASE_API_ENABLED` / `SUPABASE_API_TLS_ENABLED` override, a resolved `api.port` of `0` with the API enabled, or an unreadable/malformed project dotenv file — enforced on the local target even when nothing is configured to seed, including the `api.tls` cert/key pairing rule | +| `1` | `api.tls.cert_path` or `api.tls.key_path` points to an unreadable file (local TLS only) | +| `1` | Storage API error (non-2xx) other than vector-unavailable | +| `1` | network / connection failure to the Storage gateway | +| `1` | malformed list response (a 200 body whose shape doesn't decode) | +| `1` | unreadable `objects_path` (filesystem error during walk/upload) | +| `1` | `--project-ref` set without `--linked` (see Notes) | ## Telemetry Events Fired diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 52d179178b..a19f0a3b46 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { afterEach, beforeEach } from "vitest"; import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -14,6 +15,7 @@ import { legacyJsonResponse, legacyStatusCodeFailure, legacyTransportFailure, + legacyWithEnv, mockLegacyCliSettings, mockLegacyPlatformApiService, mockLegacyLinkedProjectCacheTracked, @@ -211,6 +213,34 @@ const VECTOR_DELETE = "/storage/v1/vector/DeleteVectorBucket"; describe("legacy seed buckets", () => { const tmp = useLegacyTempWorkdir("supabase-seed-buckets-"); + // Ambient `SUPABASE_API_*` values would shadow the dotenv fixtures below — + // the project-env walk skips keys already present in the shell env — so pin + // all six to unset for every test in this file (the ambient-override test + // sets its own value back through `legacyWithEnv`). + const API_OVERRIDE_ENV_KEYS = [ + "SUPABASE_API_ENABLED", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_API_PORT", + "SUPABASE_API_TLS_ENABLED", + "SUPABASE_API_TLS_CERT_PATH", + "SUPABASE_API_TLS_KEY_PATH", + ] as const; + let savedApiOverrideEnv: Record = {}; + beforeEach(() => { + savedApiOverrideEnv = {}; + for (const key of API_OVERRIDE_ENV_KEYS) { + savedApiOverrideEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + afterEach(() => { + for (const key of API_OVERRIDE_ENV_KEYS) { + const previous = savedApiOverrideEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); + it.live("short-circuits with no output when nothing is configured", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: 'project_id = "test"\n', @@ -223,6 +253,49 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "hard-fails a malformed SUPABASE_API_PORT even when nothing is configured to seed", + () => { + // The override decode belongs to config load, which runs before the no-op + // short-circuit — same principle as the bucket-name/size validations. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_API_PORT=not-a-port\n" }, + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Invalid config for api.port: cannot parse"); + expect(requests).toHaveLength(0); + }); + }, + ); + + it.live( + "hard-fails a broken TLS cert/key pairing even when nothing is configured to seed", + () => { + // The canonical `[api]` block (port, then TLS presence) is enforced on the + // no-op path too — same principle as the malformed-port case above. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: 'project_id = "test"\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n', + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "Missing required field in config: api.tls.key_path", + ); + expect(requests).toHaveLength(0); + }); + }, + ); + it.live("emits an empty JSON result for a no-op run (json mode)", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: 'project_id = "test"\n', @@ -1061,6 +1134,259 @@ describe("legacy seed buckets", () => { ); }); + it.live("calls the gateway on the SUPABASE_API_PORT override, not the config.toml port", () => { + // #6452: the resolved api.port must reach the storage gateway like every + // other consumer of that setting. Setup runs before the env mutation so a + // throwing mkdir/write can never leak the override into later tests. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return legacyWithEnv( + "SUPABASE_API_PORT", + "55511", + Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect([...new Set(requests.map((r) => new URL(r.url).port))]).toEqual(["55511"]); + }), + ); + }); + + it.live("honors a SUPABASE_API_PORT set only in supabase/.env", () => { + // The dotenv walk participates in the override, same as the other + // `projectEnvValues` consumers — no ambient env needed. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_PORT=55512\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect([...new Set(requests.map((r) => new URL(r.url).port))]).toEqual(["55512"]); + }); + }); + + it.live( + "switches the gateway to https when SUPABASE_API_TLS_ENABLED overrides the config", + () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_TLS_ENABLED=true\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.url.startsWith("https:"))).toBe(true); + }); + }, + ); + + it.live("rejects SUPABASE_API_PORT=0 with the canonical missing-field error", () => { + // `api.enabled` with a zero port is invalid config (`legacyValidateResolvedConfig`); + // the override must not smuggle a zero port into the gateway URL. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_PORT=0\n" }, + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Missing required field in config: api.port"); + expect(requests).toHaveLength(0); + }); + }); + + it.live("allows a zero api.port when the API is disabled, matching config validation", () => { + // The canonical zero-port rejection is gated on `api.enabled` + // (`legacyValidateResolvedConfig`); a disabled API with port 0 proceeds. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nenabled = false\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_PORT=0\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + }); + }); + + it.live("hard-fails on a malformed SUPABASE_API_PORT override before any gateway call", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_PORT=not-a-port\n" }, + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Invalid config for api.port: cannot parse"); + expect(JSON.stringify(exit)).toContain("not-a-port"); + expect(requests).toHaveLength(0); + }); + }); + + it.live("honors a SUPABASE_API_EXTERNAL_URL set only in supabase/.env", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_EXTERNAL_URL=http://127.0.0.1:55513\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.url.startsWith("http://127.0.0.1:55513/"))).toBe(true); + }); + }); + + it.live("lets an external_url override win over a port override", () => { + // `legacyResolveApiExternalUrl`: a non-empty external_url short-circuits + // the scheme://host:port derivation, so the port override is inert here. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { + "supabase/.env": + "SUPABASE_API_EXTERNAL_URL=http://127.0.0.1:55514\nSUPABASE_API_PORT=59999\n", + }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect([...new Set(requests.map((r) => new URL(r.url).port))]).toEqual(["55514"]); + }); + }); + + it.live( + "turns TLS cert validation on when SUPABASE_API_ENABLED=true overrides the config", + () => { + // The override works in both directions: a config with `enabled = false` + // skips the cert/key pairing check, so flipping it on via env must restore + // the established missing-field rejection. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[api]\nenabled = false\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n[storage.buckets.images]\npublic = true\n', + files: { "supabase/.env": "SUPABASE_API_ENABLED=true\n" }, + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "Missing required field in config: api.tls.key_path", + ); + expect(requests).toHaveLength(0); + }); + }, + ); + + it.live("skips TLS cert validation when SUPABASE_API_ENABLED=false overrides the config", () => { + // cert_path without key_path fails validation when the gate is on; the + // env-overridden `api.enabled` must switch that gate off, exactly like the + // raw config value would. + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[api.tls]\nenabled = true\ncert_path = "kong.crt"\n[storage.buckets.images]\npublic = true\n', + files: { "supabase/.env": "SUPABASE_API_ENABLED=false\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + // tls.enabled still picks the scheme; only the cert/key validation is gated. + expect(requests.every((r) => r.url.startsWith("https:"))).toBe(true); + }); + }); + + it.live("reads TLS cert/key paths supplied through env overrides", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", + files: { + "supabase/.env": + "SUPABASE_API_TLS_CERT_PATH=kong.crt\nSUPABASE_API_TLS_KEY_PATH=kong.key\n", + "supabase/kong.crt": "-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n", + "supabase/kong.key": "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----\n", + }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.url.startsWith("https:"))).toBe(true); + }); + }); + + it.live("fails on an env-supplied cert path without a key path", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_TLS_CERT_PATH=kong.crt\n" }, + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Missing required field in config: api.tls.key_path"); + expect(requests).toHaveLength(0); + }); + }); + + it.live("hard-fails on a malformed SUPABASE_API_TLS_ENABLED override", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_TLS_ENABLED=notabool\n" }, + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Invalid config for api.tls.enabled: cannot parse"); + expect(JSON.stringify(exit)).toContain("notabool"); + expect(requests).toHaveLength(0); + }); + }); + it.live("tolerates bucket entries with a missing field (Go zero value)", () => { const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 04e74e9b45..95f268681f 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1926,16 +1926,14 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // raw values) so a `SUPABASE_API_ENABLED`/`SUPABASE_API_PORT`/ // `SUPABASE_API_TLS_{ENABLED,CERT_PATH,KEY_PATH}`/`SUPABASE_API_EXTERNAL_URL` // override that actually brought Kong up on a different port/TLS/cert/ - // external URL also reaches every local Storage-gateway caller below — - // otherwise `resolveLocalBaseUrl` derives its URL from the un-overridden - // `config.api.{port,tls.enabled,external_url}` and points at a - // port/scheme/host nothing is actually listening on, and - // `validateLocalKongTls`'s own `opts.config.api.enabled && - // opts.config.api.tls.enabled` gate (`legacy-storage-credentials.ts`) - // validates against a cert/key path Kong itself isn't actually serving - // from when `api.enabled` disagrees with the raw config (Kong's own spec - // uses these same resolved `apiEnabled`/`apiTlsCertPath`/`apiTlsKeyPath` - // locals). Also folds in the + // external URL also reaches every local Storage-gateway caller below. + // `legacyResolveStorageCredentials` now folds the same `SUPABASE_API_*` + // overrides itself (`resolveLocalApiConfig`, + // `legacy-storage-credentials.ts` — #6452) and re-resolves this + // pre-folded config to identical values, so the api fold here is what + // guarantees the exact resolved-URL/TLS/cert locals Kong's own spec used + // (`apiEnabled`/`apiTlsCertPath`/`apiTlsKeyPath`) are the ones handed + // on. Also folds in the // already-resolved `values.jwtSecret`/`values.serviceRoleKey` (decrypted, // env/dotenv-overridden — the same values the real GoTrue/Storage containers // were started with) instead of the raw `config.auth.*` @@ -1982,6 +1980,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const { localKongCa } = yield* legacyResolveStorageCredentials({ projectRef: "", config: effectiveLocalStorageConfig, + projectEnvValues, }); // Shared by every gateway probe below (the bulk wait and the // storage-only recheck), so both trust the same local Kong CA. @@ -2038,6 +2037,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta config: effectiveLocalStorageConfig, document: context.loaded?.document, }, + projectEnvValues, }).pipe(Effect.result); if (Result.isFailure(seedResult)) { // No manual `legacyRollbackStart` here — the outer @@ -2083,6 +2083,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta config: effectiveLocalStorageConfig, document: context.loaded?.document, }, + projectEnvValues, }); } diff --git a/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md index 6b1bacdc14..7a586c1abb 100644 --- a/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md @@ -5,13 +5,14 @@ Copies objects between local paths and the Storage service. The scheme of `src`/ ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ------------------------------------------------------------------ | -| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | -| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create) | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | +| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | ## Files Written @@ -38,7 +39,8 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Environment Variables `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, -`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME` — same roles as `storage ls`. +`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family — same roles as `storage ls`. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. `storage` is an experimental command: `cp` requires `--experimental` diff --git a/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md index 72b84cd6e9..ae4950bdf1 100644 --- a/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md @@ -4,12 +4,13 @@ Lists objects/buckets by path prefix against the Storage gateway (local stack or ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked) | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | ## Files Written @@ -30,14 +31,15 @@ Auth: `apikey` header always; `Authorization: Bearer ` unless the key is `s ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | ---------------------------------------------------------------------- | ---------------------------------- | -| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | linked: bypass tenant key fetch; local: explicit key | no | -| `SUPABASE_AUTH_JWT_SECRET` | local: derive service-role key | no (→ `auth.jwt_secret` → default) | -| `SUPABASE_ACCESS_TOKEN` | linked: Management API auth | no (→ `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | linked: project-ref resolution, superseded by `--project-ref` when set | no | -| `SUPABASE_SERVICES_HOSTNAME` | local baseUrl host | no (→ Docker host → `127.0.0.1`) | -| `SUPABASE_EXPERIMENTAL` | experimental gate: `--experimental` equivalent | yes, unless `--experimental` given | +| Variable | Purpose | Required? | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | linked: bypass tenant key fetch; local: explicit key | no | +| `SUPABASE_AUTH_JWT_SECRET` | local: derive service-role key | no (→ `auth.jwt_secret` → default) | +| `SUPABASE_ACCESS_TOKEN` | linked: Management API auth | no (→ `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked: project-ref resolution, superseded by `--project-ref` when set | no | +| `SUPABASE_SERVICES_HOSTNAME` | local baseUrl host | no (→ Docker host → `127.0.0.1`) | +| `SUPABASE_API_PORT`, `SUPABASE_API_EXTERNAL_URL`, `SUPABASE_API_TLS_ENABLED`, `SUPABASE_API_TLS_CERT_PATH`, `SUPABASE_API_TLS_KEY_PATH`, `SUPABASE_API_ENABLED` | local: override the matching `[api]` fields for the gateway URL/TLS, shell env or project dotenv (shell wins; #6452) | no | +| `SUPABASE_EXPERIMENTAL` | experimental gate: `--experimental` equivalent | yes, unless `--experimental` given | `storage` is an experimental command: every subcommand requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with diff --git a/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md index 742b1e87d1..24a18b074c 100644 --- a/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md @@ -6,12 +6,13 @@ A direct move that returns `not_found` falls back to a recursive per-object move ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | ## Files Written @@ -33,7 +34,8 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Environment Variables `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, -`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME` — same roles as `storage ls`. +`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family — same roles as `storage ls`. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. `storage` is an experimental command: `mv` requires `--experimental` diff --git a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md index 4d3f6905d9..ed646ff99f 100644 --- a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md @@ -6,13 +6,13 @@ when `-r` is set. With no paths and `-r`, every bucket is cleared and deleted. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ----------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) | +| Path | Format | When | +| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878); on the local path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | ## Files Written @@ -36,7 +36,8 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Environment Variables `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, -`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, plus `SUPABASE_YES` (auto-confirm) — +`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, the `SUPABASE_API_*` override +family, plus `SUPABASE_YES` (auto-confirm) — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878). `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts index 7c07800c97..2e45dee543 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -232,6 +232,9 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( // auto-confirms bucket/vector/analytics prune prompts. yes, resolvedConfig: { config, document: loaded?.document }, + // The same nested-dotenv walk this function already resolved for + // `yes`/`experimental` above — no independent reload in the seed core. + projectEnvValues: projectEnv, }).pipe( // A genuinely invalid bucket entry (bad name, unparseable `file_size_limit`, …) — // recreate already dropped/rebuilt the DB, so aborting now would leave the reset 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 index 465f2f17bf..1e916178c8 100644 --- 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 @@ -4,9 +4,12 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Effect, Exit, FileSystem, Path, Schema } from "effect"; +import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; +import { legacyWithEnv, mockLegacyCliSettings } from "../../../tests/helpers/legacy-mocks.ts"; +import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; +import { legacyResolveStorageCredentials } from "./legacy-storage-credentials.ts"; import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; /** @@ -15,7 +18,10 @@ import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts" * `@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). + * text (`.toContain(...)` on both sides with the same expected string). A third caller — the + * storage-credentials resolver (S, `legacyResolveStorageCredentials`) — shares the `api.port` + * and `api.tls` presence branches through the exported helpers; its own describe block below + * drives that pipeline. * * D's harness replicates the `withConfig`/`read`/`failsWith` pattern from * `legacy-db-config.toml-read.unit.test.ts` (file-local there, not exported — faithfully @@ -279,7 +285,9 @@ const scenarios: ReadonlyArray = [ // 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. +// - `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. `api.tls` +// presence is D-skipped for the same reason but IS shared with S via +// `legacyValidateApiTlsPresence` — see the S block below. 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`, () => @@ -290,3 +298,68 @@ describe("legacyValidateResolvedConfig cross-caller parity (D vs L)", () => { ); } }); + +// The `api.port` branch is L-only in the D-vs-L table above (D has no api section), but it is +// now ALSO shared with the storage-credentials resolver (S) through `legacyValidateApiPort` +// (#6467 review). Drive S's real pipeline and L against the same zero-port config and assert +// the identical message, so the shared branch cannot drift for either caller. +describe("shared api validation branches, cross-caller parity (S vs L)", () => { + /** Drives S's real pipeline (`legacyResolveStorageCredentials`, local branch) to failure. */ + const failsWithS = (config: CliConfig, message: string) => + Effect.gen(function* () { + const dir = withConfig(""); + const exit = yield* legacyResolveStorageCredentials({ projectRef: "", config }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockLegacyCliSettings({ workdir: dir }), + // Local-path parity only — the remote branch (and with it the + // platform API) is never reached. + Layer.succeed(LegacyPlatformApiFactory, { + make: Effect.die("unreachable: local-only parity scenario"), + }), + ), + ), + Effect.exit, + ); + expect(Exit.isFailure(exit), `S: expected failure containing: ${message}`).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain(message); + } + rmSync(dir, { recursive: true, force: true }); + }); + + // Ambient SUPABASE_API_* values would override the config under test, so pin + // every participating key to unset for the duration of each scenario. + const isolated = (body: Effect.Effect) => + [ + "SUPABASE_API_PORT", + "SUPABASE_API_ENABLED", + "SUPABASE_API_TLS_ENABLED", + "SUPABASE_API_TLS_CERT_PATH", + "SUPABASE_API_TLS_KEY_PATH", + ].reduce((inner, name) => legacyWithEnv(name, undefined, inner), body); + + it.effect("api.port = 0 with the API enabled: S and L fail with the same message", () => + isolated( + Effect.gen(function* () { + const message = "Missing required field in config: api.port"; + failsWithL({ api: { port: 0 } }, message); + yield* failsWithS(baseConfig({ api: { port: 0 } }), message); + }), + ), + ); + + it.effect("api.tls cert without key: S and L fail with the same presence message", () => + isolated( + Effect.gen(function* () { + const message = "Missing required field in config: api.tls.key_path"; + failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); + yield* failsWithS( + baseConfig({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }), + message, + ); + }), + ), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index 0b6f12fd10..c19e41f4cd 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -339,6 +339,39 @@ function messageOf(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } +/** + * The canonical `api.port` branch: an enabled API with a port of `0` is + * invalid config. Exported so the storage-credentials resolver + * (`resolveLocalApiConfig`) shares the exact branch and message instead of + * re-implementing them — config validation has one home (see the module + * header and `legacy-config-validate.parity.unit.test.ts`). + */ +export function legacyValidateApiPort(enabled: boolean, port: number): void { + if (enabled && port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: api.port"); + } +} + +/** + * The canonical `api.tls` cert/key presence rule: exactly one of the two paths + * set is invalid config. Exported for the same single-home reason as + * {@link legacyValidateApiPort}; the actual cert/key file reads stay + * caller-side I/O. + */ +export function legacyValidateApiTlsPresence( + 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 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"); + } +} + /** * 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 @@ -355,18 +388,9 @@ export function legacyValidateResolvedConfig(input: LegacyConfigValidationInput) // 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"); - } + legacyValidateApiPort(input.api.enabled, input.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"); - } + legacyValidateApiTlsPresence(input.api.tls.certPath, input.api.tls.keyPath); } } diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index 383703b62a..146dc24eb0 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -13,6 +13,7 @@ import { legacyPromptYesNo } from "../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyResolveStorageCredentials, legacyStorageGatewayFetch, + legacyValidateLocalApiOverrides, } from "./legacy-storage-credentials.ts"; import { legacyParseFileSizeLimit, @@ -162,17 +163,23 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { readonly config: CliConfig; readonly document: Record | undefined; }; + /** + * Already-resolved nested project dotenv map, when the caller's own config + * resolution walked it (`db reset`'s context, `start`) — same passthrough + * idea as `resolvedConfig` above. When omitted (the standalone command), + * loaded once below and shared by the `SUPABASE_YES` fallback and the + * storage credentials `SUPABASE_API_*` fold. + */ + readonly projectEnvValues?: Readonly>; }) { const output = yield* Output; const cliSettings = yield* LegacyCliSettings; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const projectEnvValues = + opts.projectEnvValues ?? (yield* legacyLoadProjectEnv(fs, path, cliSettings.workdir)); // `--yes` OR `SUPABASE_YES`. - const yes = - opts.yes ?? - (yield* legacyResolveYesWithProjectEnv( - yield* legacyLoadProjectEnv(fs, path, cliSettings.workdir), - )); + const yes = opts.yes ?? (yield* legacyResolveYesWithProjectEnv(projectEnvValues)); const { projectRef, emitSummary } = opts; const interactive = opts.interactive ?? true; @@ -239,6 +246,13 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // Short-circuit: nothing to seed (ref present → never short-circuits). if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { + // The `SUPABASE_API_*` override decode belongs to config load, which runs + // before the no-op path — a malformed override or invalid `api.port` fails + // even with nothing to seed, same as the bucket-name/size validations + // above, including the TLS cert/key pairing rule. Validate-only: the + // seeding path re-resolves the same fold through + // `legacyResolveStorageCredentials`. + yield* legacyValidateLocalApiOverrides(config.api, projectEnvValues); if (emitSummary && output.format !== "text") { yield* output.success("", { ...emptySummary() }); } @@ -246,7 +260,11 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { } // Build the Storage service-gateway client (local or remote). - const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); + const credentials = yield* legacyResolveStorageCredentials({ + projectRef, + config, + projectEnvValues, + }); // All gateway operations run with an explicit non-DoH fetch (CA-trusting for // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup inside diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index 2c63f96e40..449c274854 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -13,7 +13,10 @@ import { * * `LegacyStorageConfigError` covers the config-load-time validations run * before the Storage API client is built (`auth.jwt_secret` length, Kong TLS cert/key pairing - * and readability). The remaining three mirror `tenant.GetApiKeys` failure + * and readability, a malformed `SUPABASE_API_*` port/bool override, an enabled + * API whose resolved `api.port` is `0`, and an unreadable/malformed project + * dotenv file — see `legacyResolveStorageCredentials`'s local branch and `resolveLocalApiConfig`). + * The remaining three mirror `tenant.GetApiKeys` failure * modes on the `--linked` path. */ export class LegacyStorageConfigError extends Data.TaggedError("LegacyStorageConfigError")<{ diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 07f3ce74d9..eea990b3f6 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -4,8 +4,15 @@ import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { legacyValidateApiPort, legacyValidateApiTlsPresence } from "./legacy-config-validate.ts"; +import { legacyLoadProjectEnv } from "./legacy-db-config.toml-read.ts"; import { legacyMapTenantApiKeysError } from "./legacy-get-tenant-api-keys.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; +import { + legacyEnvOverride, + legacyEnvOverrideBool, + legacyEnvOverridePort, +} from "./legacy-local-config-values.ts"; import { KONG_LOCAL_CA_CERT } from "./kong-local-ca-cert.ts"; import { legacyExtractServiceKeys } from "./legacy-tenant-keys.ts"; import { @@ -21,8 +28,10 @@ import { * Shared by `seed buckets` and `storage ls/cp/mv/rm`. * * - `projectRef === ""` (local): base URL from `api.external_url` (else - * `://:`), service-role key derived from - * `auth.{service_role_key,jwt_secret}`, and the Kong CA when the URL is https. + * `://:`), with the `SUPABASE_API_*` env/dotenv + * overrides folded in first (see {@link resolveLocalApiConfig}), service-role + * key derived from `auth.{service_role_key,jwt_secret}`, and the Kong CA when + * the URL is https. * - remote: base URL `https://.`; key from * `SUPABASE_AUTH_SERVICE_ROLE_KEY` else `tenant.GetApiKeys`. * @@ -59,6 +68,18 @@ interface LegacyStorageCredentials { export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly config: LegacyStorageConfigView; + /** + * Already-resolved project env map for the `SUPABASE_API_*` fold, when the + * caller has one in scope (`legacySeedBucketsRun`, `start`) — same + * passthrough idea as `legacySeedBucketsRun`'s own `resolvedConfig`. Either + * walk's shape works — a map that omits ambient-shadowed keys + * (`legacyLoadProjectEnv`) or one that overlays ambient values + * (`legacyResolveProjectEnvironmentValues`) — since the override helpers' + * `map[name] ?? process.env[name]` lookup resolves both identically. When + * omitted (the `storage` commands), the local branch loads the nested + * project dotenv walk itself. + */ + readonly projectEnvValues?: Readonly>; }) { const cliSettings = yield* LegacyCliSettings; @@ -97,7 +118,13 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseUrl = resolveLocalBaseUrl(opts.config); + const projectEnvValues = + opts.projectEnvValues ?? + (yield* legacyLoadProjectEnv(fs, path, cliSettings.workdir).pipe( + Effect.mapError((cause) => new LegacyStorageConfigError({ message: cause.message })), + )); + const api = yield* resolveLocalApiConfig(opts.config.api, projectEnvValues); + const baseUrl = legacyResolveApiExternalUrl(api, legacyGetHostname()); const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth); // `status.NewKongClient` installs unconditionally for the local client; its @@ -107,13 +134,13 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts // (the scheme derives from `api.tls.enabled` alone). let localKongCa: string | undefined; const validatedCa = - opts.config.api.enabled && opts.config.api.tls.enabled + api.enabled && api.tls.enabled ? yield* validateLocalKongTls( fs, path, cliSettings.workdir, - opts.config.api.tls.cert_path, - opts.config.api.tls.key_path, + api.tls.cert_path, + api.tls.key_path, ) : undefined; if (baseUrl.startsWith("https:")) { @@ -123,12 +150,101 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts }); /** - * Local API URL: `legacyResolveApiExternalUrl` with `legacyGetHostname` (Go's - * `utils.GetHostname`) supplying the host when `api.external_url` is unset. + * Fold the `SUPABASE_API_*` env/dotenv overrides into the `[api]` fields the + * local gateway derives its base URL and TLS material from. Every other local + * consumer of these fields already reads them post-override + * (`legacy-local-config-values.ts`'s resolvers, `start.handler.ts`'s + * `effectiveLocalStorageConfig`); without this fold, a stack brought up with + * e.g. `SUPABASE_API_PORT=54331` is unreachable here because the gateway URL + * falls back to the raw `config.toml` port (#6452). `projectEnvValues` is the + * nested project dotenv map (caller-supplied or loaded by + * `legacyResolveStorageCredentials`), so a value set only in + * `supabase/.env`(.local) counts; a caller that already folded these overrides + * (`start`) re-resolves the same map to the same values, so the fold is + * idempotent. A malformed port/bool override or an enabled API whose resolved + * port is `0` (`legacyValidateApiPort` — the canonical branch) is an + * invalid-config hard failure, same as the sibling resolvers. + * `[remotes.*]` never merges on the local path (`loadCliConfig` receives no + * `projectRef` here), so the remote-over-env precedence those resolvers apply + * does not arise. */ -function resolveLocalBaseUrl(config: LegacyStorageConfigView): string { - return legacyResolveApiExternalUrl(config.api, legacyGetHostname()); -} +const resolveLocalApiConfig = ( + api: LegacyStorageConfigView["api"], + projectEnvValues: Readonly>, +) => + Effect.try({ + try: () => { + const resolved = { + enabled: legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + api.enabled, + "api.enabled", + projectEnvValues, + ), + external_url: legacyEnvOverride( + "SUPABASE_API_EXTERNAL_URL", + api.external_url, + projectEnvValues, + ), + port: legacyEnvOverridePort("SUPABASE_API_PORT", api.port, "api.port", projectEnvValues), + tls: { + enabled: legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ), + cert_path: legacyEnvOverride( + "SUPABASE_API_TLS_CERT_PATH", + api.tls.cert_path, + projectEnvValues, + ), + key_path: legacyEnvOverride( + "SUPABASE_API_TLS_KEY_PATH", + api.tls.key_path, + projectEnvValues, + ), + }, + } satisfies LegacyStorageConfigView["api"]; + legacyValidateApiPort(resolved.enabled, resolved.port); + return resolved; + }, + // A malformed port/bool override or the canonical zero-port rejection + // collapses into the tagged storage config error, preserving the helper's + // message — the same collapse every other consumer of these throwing + // helpers applies (`wrapDbConfigOverride` → `LegacyDbConfigLoadError`), + // keeping this Effect error channel tagged. + catch: (cause) => + new LegacyStorageConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + +/** + * Validate-only entry point for `legacySeedBucketsRun`'s empty-config + * short-circuit: decodes the `SUPABASE_API_*` overrides and runs the canonical + * `[api]` config-load checks (`legacyValidateApiPort`, then the + * `legacyValidateApiTlsPresence` pairing rule) without building credentials — + * the cert/key file reads stay on the seeding path (`validateLocalKongTls`), + * where the established message precedence (jwt-secret length before TLS + * presence) is preserved. The resolved view is discarded; the seeding path + * re-resolves through `legacyResolveStorageCredentials`. + */ +export const legacyValidateLocalApiOverrides = Effect.fnUntraced(function* ( + api: LegacyStorageConfigView["api"], + projectEnvValues: Readonly>, +) { + const resolved = yield* resolveLocalApiConfig(api, projectEnvValues); + if (resolved.enabled && resolved.tls.enabled) { + yield* Effect.try({ + try: () => legacyValidateApiTlsPresence(resolved.tls.cert_path, resolved.tls.key_path), + catch: (cause) => + new LegacyStorageConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + } +}); /** * Resolve the service-role key for the local Storage gateway, mirroring Go's @@ -184,21 +300,17 @@ const validateLocalKongTls = Effect.fnUntraced(function* ( certPath: string | undefined, keyPath: string | undefined, ) { - const hasCert = certPath !== undefined && certPath.length > 0; - const hasKey = keyPath !== undefined && keyPath.length > 0; - - if (hasCert && !hasKey) { - return yield* new LegacyStorageConfigError({ - message: "Missing required field in config: api.tls.key_path", - }); - } - if (hasKey && !hasCert) { - return yield* new LegacyStorageConfigError({ - message: "Missing required field in config: api.tls.cert_path", - }); - } + // The canonical presence rule lives in `legacy-config-validate.ts`; only the + // file reads below are this caller's own I/O. + yield* Effect.try({ + try: () => legacyValidateApiTlsPresence(certPath, keyPath), + catch: (cause) => + new LegacyStorageConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); - if (hasCert) { + if (certPath !== undefined && certPath.length > 0) { // TLS paths join unconditionally with the supabase dir — NO IsAbs guard // (`path.Join` absorbs a leading "/"). const absCert = path.join(workdir, "supabase", certPath);