From c6f3ae16cbd10994d69ffc49614299ca81a9f73c Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:06:26 +0530 Subject: [PATCH] fix(cli): match legacy `[edge_runtime.secrets]` env on the stack (CLI-2577) --- apps/cli/src/command-internal/stack-config.ts | 17 ++++++++------ .../stack-forwarding.integration.test.ts | 22 +++++++++++++++++++ .../experimental/stack/start/SIDE_EFFECTS.md | 5 +++-- .../commands/functions/serve/SIDE_EFFECTS.md | 6 +++-- .../src/shared/functions/functions-config.ts | 19 +++++++++++++++- apps/cli/src/shared/functions/serve.ts | 19 +++++----------- 6 files changed, 63 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 2f89f78f36..3d67ab383f 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -20,6 +20,7 @@ import type { import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CLI_VERSION } from "../shared/cli/version.ts"; import { decryptSecretMap } from "../shared/config/vault-decrypt.ts"; +import { toEdgeRuntimeSecretsEnv } from "../shared/functions/functions-config.ts"; import { describeConfigLoadFailure, loadResolvedConfigContext, @@ -467,20 +468,22 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ), catch: (cause) => new StackConfigError({ message: String(cause) }), }); + const resolvedSecrets = yield* resolveConfigSubtree( + resolvedConfig, + validatedConfig.edge_runtime.secrets ?? {}, + "edge_runtime.secrets", + ).pipe(Effect.mapError(toStackConfigError)); const functionsEnvResult = decryptSecretMap( - Object.fromEntries( - Object.entries(validatedConfig.edge_runtime.secrets ?? {}).map(([key, value]) => [ - key, - value ?? "", - ]), - ), + toEdgeRuntimeSecretsEnv(resolvedSecrets), resolvedConfig.dotenvPrivateKeys, ); if (!functionsEnvResult.ok) return yield* new StackConfigError({ message: `failed to parse config: ${functionsEnvResult.error}`, }); - const functionsEnv = functionsEnvResult.value; + const functionsEnv = Object.fromEntries( + Object.entries(functionsEnvResult.value).filter(([name]) => !name.startsWith("SUPABASE_")), + ); const storageFileSizeLimit = yield* Effect.try({ try: () => String(parseFileSizeLimit(validatedConfig.storage.file_size_limit)), catch: (cause) => diff --git a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts index 090242a1e9..9daf58333f 100644 --- a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts @@ -221,6 +221,28 @@ SHARED = "encrypted:BOsrXIZY2BNTW43BeRhMbfvlOIUjwI7GCyFHxJD/Ik+UQ4mqkgVl2+61WWhE }).pipe(Effect.provide(layer)), ); + it.effect("uppercases edge runtime secret names and drops unset or SUPABASE_ ones", () => + Effect.gen(function* () { + const root = yield* createStackConfigProject( + `project_id = "functions-secret-names" +[edge_runtime.secrets] +my_lower = "lower-value" +FROM_ENV = "env(STACK_SECRET_SOURCE)" +MISSING = "env(STACK_SECRET_UNSET)" +EMPTY = "" +supabase_internal_wallclock_limit_sec = "7" +`, + { rootEnv: "STACK_SECRET_SOURCE=from-env\n" }, + ); + const config = yield* loadStackConfig(root); + const services = yield* config.creations("functions-secret-names"); + expect(services.find((service) => service.service === "functions")?.config.env).toEqual({ + MY_LOWER: "lower-value", + FROM_ENV: "from-env", + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("leaves empty configured secrets for the stack to normalize", () => Effect.gen(function* () { const root = yield* createStackConfigProject(`project_id = "empty-secrets" diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index c5004d88f0..ad63a0b9f2 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -24,8 +24,9 @@ S3 protocol/vector controls are forwarded to their services. Storage receives th keys and region, and uses the gateway's `/storage/v1` prefix to verify S3 signatures and to build resumable upload URLs. `analytics.vector_port` and `SUPABASE_ANALYTICS_VECTOR_PORT` are accepted and ignored: the stack runs no Vector service. -Encrypted JWT secrets are decrypted before shared credentials are derived, and encrypted -`[edge_runtime.secrets]` values are decrypted before they reach the Functions service. `db.health_timeout` +Encrypted JWT secrets are decrypted before shared credentials are derived. `[edge_runtime.secrets]` +names are uppercased, empty or unresolved `env()` values and `SUPABASE_`-prefixed names are dropped, +and encrypted values are decrypted before they reach the Functions service. `db.health_timeout` controls database readiness; package JWT and PostgreSQL root-key defaults apply when omitted, and the effective root key is supplied through a stack-owned key file. Studio receives the database connection, the Functions management directory/URL, and Analytics diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index a3b71f0f8b..7061282c64 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -133,8 +133,10 @@ when the owner remains reachable. Import-map and inspector flags are rejected be mutation. Stack mode writes no project files and does not stop or restart the whole composition. It reads the shared Functions `.env` when creating a temporary service, or the explicit `--env-file`; per-function `.env` files are not read, and reserved `SUPABASE_*` entries are ignored. -For Stack mode, TOML `edge_runtime.secrets` overrides the shared `.env`; an explicit -`--env-file` overrides matching shared secrets while retaining other saved values. +For Stack mode, TOML `edge_runtime.secrets` names are uppercased, empty or unresolved `env()` +values and `SUPABASE_`-prefixed names are dropped, encrypted values are decrypted, and the result +overrides the shared `.env`; an explicit `--env-file` overrides matching shared secrets while +retaining other saved values. Per-function TOML `env` remains the final override for that function. Without a configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a per-function `import_map.json` is not read and does not fail the command. diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts index 374f67d9ef..69c3457eab 100644 --- a/apps/cli/src/shared/functions/functions-config.ts +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, type FileSystem, Option, type Path } from "effect"; +import { Crypto, Effect, type FileSystem, Option, type Path, Redacted } from "effect"; import type { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import type { LoadedCliConfig } from "@supabase/config/effect"; import { CliConfigValues, type ResolvedCliConfig } from "../../config/cli-config-values.service.ts"; @@ -74,3 +74,20 @@ export const loadFunctionsCliConfig = Effect.fn("FunctionsConfig.load")(function denoVersion: context.denoVersion, } satisfies FunctionsCliConfigContext; }); + +/** + * `[edge_runtime.secrets]` as the Functions container env: names uppercased, only resolved + * non-empty values kept. An unresolved `env(VAR)` literal stays a plain string, not + * `Redacted`, so it is dropped, the same guard `secrets set` uses. + */ +export function toEdgeRuntimeSecretsEnv( + secrets: Readonly | undefined>> | undefined, +): Record { + return Object.fromEntries( + Object.entries(secrets ?? {}).flatMap(([name, value]): Array<[string, string]> => + Redacted.isRedacted(value) && Redacted.value(value).length > 0 + ? [[name.toUpperCase(), Redacted.value(value)]] + : [], + ), + ); +} diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index dc4be8ed44..e01e472a6e 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -81,7 +81,11 @@ import { runChildProcess, toDockerPath, } from "./functions-docker.ts"; -import { loadFunctionsCliConfig, type FunctionsLocalConfigLoader } from "./functions-config.ts"; +import { + loadFunctionsCliConfig, + toEdgeRuntimeSecretsEnv, + type FunctionsLocalConfigLoader, +} from "./functions-config.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; import { slimImagesEnabled } from "../services/slim-images.ts"; import { @@ -438,18 +442,7 @@ export function toPlainEdgeRuntimeConfig( policy: reveal(edgeRuntime.policy) ?? "", inspector_port: edgeRuntime.inspector_port, deno_version: edgeRuntime.deno_version, - // Secret names always reach the container env uppercased regardless of - // authored casing. Only resolved (non-empty) values are kept — an - // unresolved `env(VAR)` literal stays a plain string, not `Redacted`, so - // `Redacted.isRedacted` + non-empty filters it out, the same guard - // `secrets set` uses. - secrets: Object.fromEntries( - Object.entries(edgeRuntime.secrets ?? {}).flatMap(([name, value]) => - Redacted.isRedacted(value) && Redacted.value(value).length > 0 - ? [[name.toUpperCase(), Redacted.value(value)] as const] - : [], - ), - ), + secrets: toEdgeRuntimeSecretsEnv(edgeRuntime.secrets), }; }