Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import { resolveLocalConfigValues } from "./local-config-values.ts";
* 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). A third caller — the
* storage-credentials resolver (S, `resolveStorageCredentials`) — shares the `api.port`
* and `api.tls` presence branches through the exported helpers; its own describe block below
* drives that pipeline.
* and `api.tls` presence branches through the exported helpers and the `auth.jwt_secret` /
* `auth.service_role_key` resolution (length rule, `encrypted:` decryption); its own describe
* block below drives that pipeline.
*
* D's harness replicates the `withConfig`/`read`/`failsWith` pattern from
* `db-config.toml-read.unit.test.ts` (file-local there, not exported — faithfully
Expand Down Expand Up @@ -319,9 +320,10 @@ describe("validateResolvedConfig 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 `validateApiPort`
// (#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)", () => {
// (#6467 review), as are the `auth.jwt_secret` length rule and `encrypted:` decryption
// (#6467 follow-up). Drive S's real pipeline and L against the same misconfiguration and assert
// the identical message, so the shared branches cannot drift for either caller.
describe("shared api + auth validation branches, cross-caller parity (S vs L)", () => {
/** Drives S's real pipeline (`resolveStorageCredentials`, local branch) to failure. */
const failsWithS = (config: CliConfig, message: string) =>
Effect.gen(function* () {
Expand All @@ -347,15 +349,18 @@ describe("shared api validation branches, cross-caller parity (S vs L)", () => {
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.
// Ambient SUPABASE_API_* / SUPABASE_AUTH_* values would override the config
// under test, so pin every participating key to unset for the duration of
// each scenario.
const isolated = <A, E, R>(body: Effect.Effect<A, E, R>) =>
[
"SUPABASE_API_PORT",
"SUPABASE_API_ENABLED",
"SUPABASE_API_TLS_ENABLED",
"SUPABASE_API_TLS_CERT_PATH",
"SUPABASE_API_TLS_KEY_PATH",
"SUPABASE_AUTH_JWT_SECRET",
"SUPABASE_AUTH_SERVICE_ROLE_KEY",
].reduce((inner, name) => withEnvVar(name, undefined, inner), body);

it.effect("api.port = 0 with the API enabled: S and L fail with the same message", () =>
Expand All @@ -380,4 +385,27 @@ describe("shared api validation branches, cross-caller parity (S vs L)", () => {
}),
),
);

it.effect("auth.jwt_secret shorter than 16 characters: S and L fail with the same message", () =>
isolated(
Effect.gen(function* () {
const message = "Invalid config for auth.jwt_secret. Must be at least 16 characters";
failsWithL({ auth: { jwt_secret: "short" } }, message);
yield* failsWithS(baseConfig({ auth: { jwt_secret: "short" } }), message);
}),
),
);

it.effect(
"undecryptable encrypted: auth.service_role_key: S and L fail with the same message",
() =>
isolated(
Effect.gen(function* () {
const message = "failed to parse config";
const auth = { service_role_key: "encrypted:not-a-real-ciphertext" };
failsWithL({ auth }, message);
yield* failsWithS(baseConfig({ auth }), message);
}),
),
);
});
16 changes: 12 additions & 4 deletions apps/cli/src/command-internal/local-config-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,9 +572,11 @@ export function envOverrideMaxClientConn(
* Applied AFTER {@link envOverride}: an env-sourced override lands on the
* same field and goes through the same decrypt step as a TOML-sourced value,
* so `SUPABASE_AUTH_JWT_SECRET=encrypted:...` is decrypted too, not just the
* config.toml value.
* config.toml value. Exported for the storage-credentials resolver
* (`resolveLocalServiceRoleKey`), which applies the same composition to
* `auth.{jwt_secret,service_role_key}` for the local Storage gateway.
*/
function decryptAuthSecret(
export function decryptAuthSecret(
value: string | undefined,
projectEnvValues: Readonly<Record<string, string>> | undefined,
): string | undefined {
Expand Down Expand Up @@ -788,8 +790,14 @@ export function resolveAuthCaptcha(
: undefined;
}

/** `(a *auth) generateAPIKeys`. */
function resolveJwtSecret(configured: string | undefined): string {
/**
* Resolve the signing secret from the (override-applied, decrypted)
* `auth.jwt_secret`: empty falls back to `defaultJwtSecret`, shorter than
* {@link MIN_JWT_SECRET_LENGTH} throws {@link InvalidJwtSecretError}.
* Exported for the storage-credentials resolver (`resolveLocalServiceRoleKey`),
* which derives the local Storage gateway's service-role key from it.
*/
export function resolveJwtSecret(configured: string | undefined): string {
if (configured === undefined || configured.length === 0) return defaultJwtSecret;
if (configured.length < MIN_JWT_SECRET_LENGTH) {
throw new InvalidJwtSecretError();
Expand Down
16 changes: 8 additions & 8 deletions apps/cli/src/command-internal/seed-buckets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { promptYesNo } from "./prompt-yes-no.ts";
import {
resolveStorageCredentials,
storageGatewayFetch,
validateLocalApiOverrides,
validateLocalStorageConfig,
} from "./storage-credentials.ts";
import { parseFileSizeLimit, resolveBucketProps } from "./storage-bucket-config.ts";
import {
Expand Down Expand Up @@ -249,13 +249,13 @@ export const seedBucketsRun = 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
// `resolveStorageCredentials`.
yield* validateLocalApiOverrides(config.api, projectEnvValues);
// The `SUPABASE_API_*`/`SUPABASE_AUTH_*` override decode belongs to config
// load, which runs before the no-op path — a malformed override, invalid
// `api.port`, short or undecryptable auth secret, or broken TLS cert/key
// pairing fails even with nothing to seed, same as the bucket-name/size
// validations above. Validate-only: the seeding path re-resolves the same
// values through `resolveStorageCredentials`.
yield* validateLocalStorageConfig(config, projectEnvValues);
if (emitSummary && output.format !== "text") {
yield* output.success("", { ...emptySummary() });
}
Expand Down
11 changes: 7 additions & 4 deletions apps/cli/src/command-internal/storage-credentials.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import {
* `seed buckets` and `storage ls/cp/mv/rm`.
*
* `StorageConfigError` 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, a malformed `SUPABASE_API_*` port/bool override, an enabled
* API whose resolved `api.port` is `0`, and an unreadable/malformed project
* dotenv file — see `resolveStorageCredentials`'s local branch and `resolveLocalApiConfig`).
* before the Storage API client is built (`auth.jwt_secret` length, an
* undecryptable `encrypted:` `auth.jwt_secret`/`auth.service_role_key` (or
* `SUPABASE_AUTH_*` override), Kong TLS cert/key pairing 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
* `resolveStorageCredentials`'s local branch, `resolveLocalApiConfig`, and
* `resolveLocalServiceRoleKey`).
* The remaining three mirror `tenant.GetApiKeys` failure
* modes on the `--linked` path.
*/
Expand Down
172 changes: 96 additions & 76 deletions apps/cli/src/command-internal/storage-credentials.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { defaultJwtSecret, generateJwt } from "../shared/stack-constants.ts";
import { Effect, FileSystem, Path } from "effect";

import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts";
Expand All @@ -7,8 +6,15 @@ import { resolveApiExternalUrl } from "./api-url.ts";
import { validateApiPort, validateApiTlsPresence } from "./config-validate.ts";
import { loadProjectEnv } from "./db-config.toml-read.ts";
import { mapTenantApiKeysError } from "./get-tenant-api-keys.ts";
import { generateGoJwt } from "./go-jwt.ts";
import { getHostname } from "./hostname.ts";
import { envOverride, envOverrideBool, envOverridePort } from "./local-config-values.ts";
import {
decryptAuthSecret,
envOverride,
envOverrideBool,
envOverridePort,
resolveJwtSecret,
} from "./local-config-values.ts";
import { KONG_LOCAL_CA_CERT } from "./kong-local-ca-cert.ts";
import { extractServiceKeys } from "./tenant-keys.ts";
import {
Expand All @@ -26,8 +32,9 @@ import {
* - `projectRef === ""` (local): base URL from `api.external_url` (else
* `<scheme>://<host>:<api.port>`), 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.
* key derived from `auth.{service_role_key,jwt_secret}` with their
* `SUPABASE_AUTH_*` env/dotenv overrides applied and decrypted (see
* {@link resolveLocalServiceRoleKey}), and the Kong CA when the URL is https.
* - remote: base URL `https://<ref>.<projectHost>`; key from
* `SUPABASE_AUTH_SERVICE_ROLE_KEY` else `tenant.GetApiKeys`.
*
Expand Down Expand Up @@ -65,12 +72,12 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: {
readonly projectRef: string;
readonly config: StorageConfigView;
/**
* Already-resolved project env map for the `SUPABASE_API_*` fold, when the
* caller has one in scope (`seedBucketsRun`, `start`) — same
* passthrough idea as `seedBucketsRun`'s own `resolvedConfig`. Either
* walk's shape works — a map that omits ambient-shadowed keys
* (`loadProjectEnv`) or one that overlays ambient values
* (`resolveProjectEnvironmentValues`) — since the override helpers'
* Already-resolved project env map for the `SUPABASE_API_*` fold and the
* local auth-key resolution, when the caller has one in scope
* (`seedBucketsRun`, `start`) — same passthrough idea as `seedBucketsRun`'s
* own `resolvedConfig`. Either walk's shape works — a map that omits
* ambient-shadowed keys (`loadProjectEnv`) or one that overlays ambient
* values (`resolveProjectEnvironmentValues`) — 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.
Expand Down Expand Up @@ -121,7 +128,7 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: {
));
const api = yield* resolveLocalApiConfig(opts.config.api, projectEnvValues);
const baseUrl = resolveApiExternalUrl(api, getHostname());
const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth);
const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth, projectEnvValues);

// `status.NewKongClient` installs unconditionally for the local client; its
// embedded CA only matters for https. `(*api).Validate` resolves cert_path /
Expand All @@ -145,6 +152,19 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: {
return { baseUrl, apiKey, localKongCa } satisfies StorageCredentials;
});

/**
* The config-load helpers this module composes (`envOverride*`,
* `decryptAuthSecret`, `resolveJwtSecret`, `validateApi*`) report invalid
* config by throwing. Each throw collapses into the tagged storage config
* error with the helper's message preserved — the same collapse every other
* consumer of these helpers applies (`wrapDbConfigOverride` →
* `DbConfigLoadError`) — keeping this Effect error channel tagged.
*/
const toStorageConfigError = (cause: unknown) =>
new StorageConfigError({
message: cause instanceof Error ? cause.message : String(cause),
});

/**
* 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
Expand Down Expand Up @@ -193,79 +213,82 @@ const resolveLocalApiConfig = (
validateApiPort(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` → `DbConfigLoadError`),
// keeping this Effect error channel tagged.
catch: (cause) =>
new StorageConfigError({
message: cause instanceof Error ? cause.message : String(cause),
}),
catch: toStorageConfigError,
});

/**
* Validate-only entry point for `seedBucketsRun`'s empty-config
* short-circuit: decodes the `SUPABASE_API_*` overrides and runs the canonical
* `[api]` config-load checks (`validateApiPort`, then the
* `validateApiTlsPresence` 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 `resolveStorageCredentials`.
* Resolve the service-role key for the local Storage gateway:
* - jwt secret: `SUPABASE_AUTH_JWT_SECRET` (shell or project dotenv) →
* `auth.jwt_secret` → `defaultJwtSecret`; a resolved secret shorter than 16
* chars is rejected (`resolveJwtSecret`);
* - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` (shell or project
* dotenv) → `auth.service_role_key` → sign from the resolved secret.
*
* Both fields go through the same `envOverride` → `decryptAuthSecret`
* composition the status/stop resolver applies to them
* (`local-config-values.ts`), in the same order (jwt secret first, so a short
* secret is reported before a broken service-role key), so a value set only in
* `supabase/.env`(.local) counts and a dotenvx `encrypted:` value is decrypted
* instead of being used as literal key material. An undecryptable value is an
* invalid-config hard failure, same as those siblings. As with the `[api]` fold
* above, `[remotes.*]` never merges on the local path, so the remote-over-env
* precedence those siblings gate on does not arise. The derivation itself stays
* symmetric (`generateGoJwt` from the secret — the same signer those siblings
* use, so without `auth.signing_keys_path` the minted token is the one `status`
* prints); `start` pre-folds its signing-keys-aware key for the
* `auth.signing_keys_path` case.
*
* Empty checks use length, so an explicit `service_role_key = ""` is
* regenerated (not sent as the empty string).
*/
export const validateLocalApiOverrides = Effect.fnUntraced(function* (
api: StorageConfigView["api"],
const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (
auth: StorageConfigView["auth"],
projectEnvValues: Readonly<Record<string, string>>,
) {
const resolved = yield* resolveLocalApiConfig(api, projectEnvValues);
if (resolved.enabled && resolved.tls.enabled) {
yield* Effect.try({
try: () => validateApiTlsPresence(resolved.tls.cert_path, resolved.tls.key_path),
catch: (cause) =>
new StorageConfigError({
message: cause instanceof Error ? cause.message : String(cause),
}),
});
}
const jwtSecret = yield* Effect.try({
try: () =>
resolveJwtSecret(
decryptAuthSecret(
envOverride("SUPABASE_AUTH_JWT_SECRET", auth.jwt_secret, projectEnvValues),
projectEnvValues,
),
),
catch: toStorageConfigError,
});
const configuredKey = yield* Effect.try({
try: () =>
decryptAuthSecret(
envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, projectEnvValues),
projectEnvValues,
),
catch: toStorageConfigError,
});
return configuredKey !== undefined && configuredKey.length > 0
? configuredKey
: generateGoJwt(jwtSecret, "service_role");
});

/**
* Resolve the service-role key for the local Storage gateway, mirroring Go's
* `(*auth).generateAPIKeys` + the Viper
* `AutomaticEnv`/`SUPABASE_` prefix precedence:
* - jwt secret: `SUPABASE_AUTH_JWT_SECRET` → `auth.jwt_secret` → `defaultJwtSecret`;
* a resolved secret shorter than 16 chars is rejected;
* - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` → `auth.service_role_key`
* → sign from the resolved secret.
*
* Empty checks use length, so an explicit `service_role_key = ""` is regenerated
* like Go (not sent as the empty string).
* Validate-only entry point for `seedBucketsRun`'s empty-config short-circuit:
* runs the config-load checks of the local branch in the seeding path's order —
* the `SUPABASE_API_*` decode + `validateApiPort`, the auth override/decrypt +
* jwt-secret length, then the `validateApiTlsPresence` pairing rule — without
* building credentials. The cert/key file reads stay on the seeding path
* (`validateLocalKongTls`). The resolved values are discarded; the seeding path
* re-resolves through `resolveStorageCredentials`.
*/
const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: {
readonly jwt_secret?: string;
readonly service_role_key?: string;
}) {
const envSecret = process.env["SUPABASE_AUTH_JWT_SECRET"];
const configuredSecret =
envSecret !== undefined && envSecret.length > 0 ? envSecret : auth.jwt_secret;

let jwtSecret: string;
if (configuredSecret === undefined || configuredSecret.length === 0) {
jwtSecret = defaultJwtSecret;
} else if (configuredSecret.length < 16) {
return yield* new StorageConfigError({
message: "Invalid config for auth.jwt_secret. Must be at least 16 characters",
export const validateLocalStorageConfig = Effect.fnUntraced(function* (
config: StorageConfigView,
projectEnvValues: Readonly<Record<string, string>>,
) {
const api = yield* resolveLocalApiConfig(config.api, projectEnvValues);
yield* resolveLocalServiceRoleKey(config.auth, projectEnvValues);
if (api.enabled && api.tls.enabled) {
yield* Effect.try({
try: () => validateApiTlsPresence(api.tls.cert_path, api.tls.key_path),
catch: toStorageConfigError,
});
} else {
jwtSecret = configuredSecret;
}

const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"];
const configuredKey = envKey !== undefined && envKey.length > 0 ? envKey : auth.service_role_key;
return configuredKey !== undefined && configuredKey.length > 0
? configuredKey
: generateJwt(jwtSecret, "service_role");
});

/**
Expand All @@ -288,10 +311,7 @@ const validateLocalKongTls = Effect.fnUntraced(function* (
// file reads below are this caller's own I/O.
yield* Effect.try({
try: () => validateApiTlsPresence(certPath, keyPath),
catch: (cause) =>
new StorageConfigError({
message: cause instanceof Error ? cause.message : String(cause),
}),
catch: toStorageConfigError,
});

if (certPath !== undefined && certPath.length > 0) {
Expand Down
Loading