Skip to content
6 changes: 0 additions & 6 deletions apps/cli/src/legacy/commands/secrets/secrets.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,3 @@ export class LegacySecretsUnsetCancelledError extends Data.TaggedError(
)<{
readonly message: string;
}> {}

export class LegacySecretsConfigParseError extends Data.TaggedError(
"LegacySecretsConfigParseError",
)<{
readonly message: string;
}> {}
24 changes: 12 additions & 12 deletions apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

## Files Read

| Path | Format | When |
| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- |
| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) |
| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` |
| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses |
| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss |
| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset |
| `<workdir>/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig` |
| `<workdir>/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values |
| `<workdir>/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context |
| `<env-file>` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided |
| Path | Format | When |
| ----------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) |
| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` |
| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses |
| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss |
| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset |
| `<workdir>/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig`; a parse failure is logged to the debug logger and tolerated (Go parity), not fatal |
| `<workdir>/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values |
| `<workdir>/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context |
| `<env-file>` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided |

## Files Written

Expand Down Expand Up @@ -52,7 +52,6 @@
| `1` | `LegacyInvalidSecretPairError` — positional argument missing `=` |
| `1` | `LegacySecretsEnvFileOpenError``--env-file` cannot be opened |
| `1` | `LegacySecretsEnvFileParseError``--env-file` cannot be parsed |
| `1` | `LegacySecretsConfigParseError``supabase/config.toml` cannot be parsed |
| `1` | `LegacySecretsSetUnexpectedStatusError` — non-2xx response from POST |
| `1` | `LegacySecretsSetNetworkError` — transport-level network failure |

Expand Down Expand Up @@ -85,4 +84,5 @@ One `result` NDJSON event on success containing `{project_ref, count}`.
- Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file).
- `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning.
- `[edge_runtime.secrets]` from config.toml is read via `@supabase/config`'s `loadProjectConfig` + `resolveProjectSubtree`. Resolved secret values arrive wrapped in `Redacted<string>`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler — matches Go's `set.go:48-52` which filters by `len(secret.SHA256) > 0` (the SHA256 is empty when `DecryptSecretHookFunc` sees a still-literal `env(VAR)`).
- A malformed `config.toml` does **not** abort the command — matches Go's `set.go:20-24`, which logs the `LoadConfig` error to the debug logger and proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class, matching Go's `viper`+`mapstructure` decode (`pkg/config/config.go:749`), which mutates the target struct field-by-field: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error.
- Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers — Go parity.
139 changes: 127 additions & 12 deletions apps/cli/src/legacy/commands/secrets/set/set.handler.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { loadProjectConfig, loadProjectEnvironment, resolveProjectSubtree } from "@supabase/config";
import {
loadProjectConfig,
loadProjectEnvironment,
ProjectConfigSchema,
resolveProjectSubtree,
type ProjectConfig,
type ProjectConfigParseError,
} from "@supabase/config";
import { parse as parseDotenv } from "dotenv";
import { Effect, FileSystem, Option, Path, Redacted } from "effect";
import { Effect, FileSystem, Option, Path, Redacted, Schema } from "effect";

import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts";
import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts";
import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts";
import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts";
import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts";
import { Output } from "../../../../shared/output/output.service.ts";
import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts";
import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts";
import {
LegacyInvalidSecretPairError,
LegacySecretsConfigParseError,
LegacySecretsEnvFileOpenError,
LegacySecretsEnvFileParseError,
LegacySecretsNoArgumentsError,
Expand All @@ -27,12 +34,91 @@ const mapSetError = mapLegacyHttpError({
statusMessage: (_status, body) => `Unexpected error setting project secrets: ${body}`,
});

const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema);

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

/**
* Best-effort recovery for a schema-decode failure (as opposed to a raw
* TOML/JSON parse failure) on `supabase/config.toml`. Go's `viper`+
* `mapstructure` decode (`apps/cli-go/pkg/config/config.go:749`) mutates the
* target struct field-by-field: a type error anywhere — an unrelated
* top-level table (`analytics.port`), a sibling field inside the same
* `edge_runtime` table (`edge_runtime.inspector_port`), *or* a single bad
* entry inside the `edge_runtime.secrets` map itself (`BAD = 123`) — does not
* stop the rest of `edge_runtime.secrets` from landing in `utils.Config`.
* `UnmarshalExact` still populates every field (and every map entry) it *can*
* decode before aggregating errors: `mapstructure`'s map decoder
* (`decodeMapFromMap`) iterates each key independently, appends a per-entry
* error and `continue`s rather than aborting, then still calls `val.Set` with
* whatever entries succeeded. Confirmed empirically against this repo's
* actual `pkg/config`: a TOML with both a malformed `edge_runtime.inspector_port`
* and a valid `[edge_runtime.secrets]` block still yields a populated
* `EdgeRuntime.Secrets` (`InspectorPort` is left at its zero value), and a
* `[edge_runtime.secrets]` block with one bad entry alongside a good one
* still yields the good entry.
* `Schema.decodeUnknownSync` has no such tolerance; a single bad field
* anywhere discards the whole decode — re-decoding the *entire* `edge_runtime`
* subtree would still fail in the sibling-field case (`inspector_port` comes
* along for the ride), and re-decoding the whole `secrets` map atomically
* would still fail when just one entry in that map is bad. To keep
* `secrets set` at parity without loosening `packages/config`'s decode
* semantics for every caller: re-slice `edge_runtime.secrets` out of the
* pre-decode document (`cause.document` — only set when the document itself
* parsed fine and the *schema* decode is what failed, see
* `ProjectConfigParseError`), decode each entry independently and keep only
* the ones that succeed (mirroring `decodeMapFromMap`'s per-key tolerance),
* then decode the filtered map against the full schema, where every other
* field (including the rest of `edge_runtime`) defaults cleanly. A true parse
* failure (`cause.document` undefined) has no recoverable structure in either
* implementation — Go's own `viper.MergeConfig` also fails the whole load
* before `mapstructure` ever runs in that case.
*/
function recoverEdgeRuntimeConfig(cause: ProjectConfigParseError): ProjectConfig | null {
if (cause.document === undefined) {
return null;
}
const edgeRuntime = cause.document.edge_runtime;
const secrets = isRecord(edgeRuntime) ? edgeRuntime.secrets : undefined;
const decodableSecrets = isRecord(secrets) ? filterDecodableSecrets(secrets) : undefined;
try {
return decodeProjectConfig({
Comment thread
Coly010 marked this conversation as resolved.
edge_runtime: decodableSecrets !== undefined ? { secrets: decodableSecrets } : {},
});
Comment thread
Coly010 marked this conversation as resolved.
} catch {
return null;
}
}

/**
* Mirrors mapstructure's per-entry map decode tolerance
* (`decodeMapFromMap`, invoked via `v.UnmarshalExact` in
* `apps/cli-go/pkg/config/config.go:749`): a decode error on one secret
* value doesn't discard the whole `[edge_runtime.secrets]` map — only that
* entry is dropped, and every other entry is still recovered.
*/
function filterDecodableSecrets(secrets: Record<string, unknown>): Record<string, unknown> {
const kept: Record<string, unknown> = {};
for (const [name, value] of Object.entries(secrets)) {
try {
decodeProjectConfig({ edge_runtime: { secrets: { [name]: value } } });
kept[name] = value;
} catch {
// Drop this entry only, matching mapstructure's per-key error handling.
}
}
return kept;
}

export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* (
flags: LegacySecretsSetFlags,
) {
const output = yield* Output;
const api = yield* LegacyPlatformApi;
const resolver = yield* LegacyProjectRefResolver;
const debugLogger = yield* LegacyDebugLogger;
const linkedProjectCache = yield* LegacyLinkedProjectCache;
const telemetryState = yield* LegacyTelemetryState;
const runtimeInfo = yield* RuntimeInfo;
Expand All @@ -53,23 +139,52 @@ 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<string, string>();
const loaded = yield* loadProjectConfig(runtimeInfo.cwd).pipe(
Effect.catchTag("ProjectConfigParseError", (cause) =>
Effect.fail(
new LegacySecretsConfigParseError({
message: `failed to parse supabase/config.toml: ${String(cause.cause)}`,
}),
),
// Go swallows a malformed config.toml (or a malformed `.env`/`.env.local`
// sibling — see the `ProjectEnvParseError` catch below) here
// (`internal/secrets/set/set.go:20-24`: `fmt.Fprintln(utils.GetDebugLogger(), err)`)
// and proceeds with an empty `EdgeRuntime.Secrets` — env-file and
// positional-arg secrets still work. `secrets set` has no
// `--linked`/`--local`/`--db-url` flag, so (unlike most commands) the root
// `PreRun` never loads the config first either; this is the only load, and
// it must not be fatal.
const loadedConfig = yield* loadProjectConfig(runtimeInfo.cwd).pipe(
Effect.map((loaded) => loaded?.config ?? null),
Effect.catchTag("ProjectConfigParseError", (cause) => {
Comment thread
Coly010 marked this conversation as resolved.
Comment thread
Coly010 marked this conversation as resolved.
// `smol-toml`'s `TomlError` (and some schema-decode errors) embed a
// source codeblock after a blank-line separator — literal file content,
// which for this file's `[edge_runtime.secrets]` section can include
// real secret values. Go's equivalent log line (`DecodeError.Error()`)
// is a short, content-free message; only its unused `.String()` method
// includes a snippet, and Go's `set.go:20-24` never calls it. Truncate
// before the separator so a syntax error next to a secret line can't
// echo that secret to `--debug` output.
const shortMessage = String(cause.cause).split("\n\n")[0];
return debugLogger
.debug(`failed to parse supabase/config.toml: ${shortMessage}`)
.pipe(Effect.as(recoverEdgeRuntimeConfig(cause)));
}),
// `loadProjectConfig` resolves `env(VAR)` references against
// `.env`/`.env.local` (`loadProjectEnvironment` inside
// `loadProjectConfigFile`) *before* schema decode, so a malformed dotenv
// line fails with this distinct tag rather than `ProjectConfigParseError`.
// Go's `Load()` (`pkg/config/config.go:788-791`) calls `loadNestedEnv`
// first too and returns immediately on error, before `loadFromFile` (the
// TOML parse) ever runs — so `EdgeRuntime.Secrets` never gets populated
// in this failure path, unlike the schema-decode-only case above. Recover
// to `null`, not `recoverEdgeRuntimeConfig`: there is no parsed document
// to recover a subtree from.
Effect.catchTag("ProjectEnvParseError", (cause) =>
debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)),
),
);
if (loaded !== null) {
if (loadedConfig !== null) {
const projectEnv = yield* loadProjectEnvironment({
cwd: runtimeInfo.cwd,
baseEnv: process.env,
});
if (projectEnv !== null) {
const resolved = yield* resolveProjectSubtree(
loaded.config.edge_runtime,
loadedConfig.edge_runtime,
projectEnv,
"edge_runtime",
);
Expand Down
Loading