From 82cc40130557aa304438842e2ea9fb7957e5e408 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 10 Nov 2026 13:20:17 +0000 Subject: [PATCH 01/12] feat(cli): add supabase config validate (CLI-2866) Validates the selected config file (TOML, JSON or JSONC) through the same load and validateResolvedConfig path commands use, including every [remotes.*] block, and reports every problem with its file, line and column instead of stopping at the first. Text output renders each problem with a masked source frame and a suggestion; --output-format json emits a versioned payload of codes, key paths and positions. Unresolved env() references are warnings. The command exits 1 when the config has errors and needs no network, Docker, login or linked project. --- apps/cli/bench/workloads.pr.json | 1 + apps/cli/docs/supabase/config/validate.md | 95 +++ apps/cli/docs/tracing-monitoring.md | 12 +- .../config-validate.parity.unit.test.ts | 32 +- .../src/command-internal/config-validate.ts | 561 +++++++++---- .../config-validate.unit.test.ts | 145 ++++ .../command-internal/local-config-values.ts | 411 +++++++--- .../local-config-values.unit.test.ts | 1 + .../cli/src/commands/config/config.command.ts | 8 +- .../commands/config/validate/SIDE_EFFECTS.md | 73 ++ .../config/validate/validate.command.ts | 60 ++ .../config/validate/validate.errors.ts | 33 + .../config/validate/validate.format.ts | 377 +++++++++ .../validate/validate.format.unit.test.ts | 223 +++++ .../config/validate/validate.handler.ts | 607 ++++++++++++++ .../validate/validate.integration.test.ts | 766 ++++++++++++++++++ .../config/validate/validate.model.ts | 217 +++++ .../validate/validate.model.unit.test.ts | 156 ++++ .../src/config/cli-config-key-annotations.ts | 9 + apps/cli/src/config/cli-config-key.ts | 5 + apps/cli/src/config/cli-config-remote.ts | 30 +- .../src/config/cli-config-schema-issues.ts | 88 ++ .../cli-config-schema-issues.unit.test.ts | 69 ++ .../cli/src/config/cli-config-unknown-keys.ts | 89 ++ .../cli-config-unknown-keys.unit.test.ts | 52 ++ .../cli-config-values.integration.test.ts | 77 ++ .../cli/src/config/cli-config-values.layer.ts | 171 ++-- .../src/config/cli-config-values.service.ts | 12 + apps/cli/src/config/cli-config.errors.ts | 2 + .../shared/config/cli-config-load.errors.ts | 2 + .../init/project-init.templates.unit.test.ts | 4 +- .../telemetry/__fixtures__/error-tags.txt | 3 + packages/config/AGENTS.md | 2 +- packages/config/src/config-edit.ts | 10 +- packages/config/src/config-locate.ts | 380 +++++++++ .../config/src/config-locate.unit.test.ts | 222 +++++ .../config/src/entrypoint-purity.unit.test.ts | 3 + packages/config/src/internal.ts | 4 +- packages/config/src/io.ts | 43 +- packages/config/src/removed-config-keys.ts | 43 +- packages/config/src/unknown-keys.ts | 50 +- packages/config/src/unknown-keys.unit.test.ts | 15 +- packages/config/src/validate.ts | 25 +- packages/config/src/validate.unit.test.ts | 18 + 44 files changed, 4825 insertions(+), 381 deletions(-) create mode 100644 apps/cli/docs/supabase/config/validate.md create mode 100644 apps/cli/src/commands/config/validate/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/config/validate/validate.command.ts create mode 100644 apps/cli/src/commands/config/validate/validate.errors.ts create mode 100644 apps/cli/src/commands/config/validate/validate.format.ts create mode 100644 apps/cli/src/commands/config/validate/validate.format.unit.test.ts create mode 100644 apps/cli/src/commands/config/validate/validate.handler.ts create mode 100644 apps/cli/src/commands/config/validate/validate.integration.test.ts create mode 100644 apps/cli/src/commands/config/validate/validate.model.ts create mode 100644 apps/cli/src/commands/config/validate/validate.model.unit.test.ts create mode 100644 apps/cli/src/config/cli-config-schema-issues.ts create mode 100644 apps/cli/src/config/cli-config-schema-issues.unit.test.ts create mode 100644 apps/cli/src/config/cli-config-unknown-keys.ts create mode 100644 apps/cli/src/config/cli-config-unknown-keys.unit.test.ts create mode 100644 packages/config/src/config-locate.ts create mode 100644 packages/config/src/config-locate.unit.test.ts diff --git a/apps/cli/bench/workloads.pr.json b/apps/cli/bench/workloads.pr.json index d599457f3d..4222de8f68 100644 --- a/apps/cli/bench/workloads.pr.json +++ b/apps/cli/bench/workloads.pr.json @@ -4,6 +4,7 @@ { "id": "help", "args": ["--help"], "cwdSetup": "none" }, { "id": "init", "args": ["init"], "cwdSetup": "none" }, { "id": "migration-new", "args": ["migration", "new", "bench"], "cwdSetup": "init" }, + { "id": "config-validate", "args": ["config", "validate"], "cwdSetup": "init" }, { "id": "gen-types", "args": ["gen", "types", "--lang", "typescript", "--project-id", "replayfixtureprojref"], diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md new file mode 100644 index 0000000000..08e37e8c3f --- /dev/null +++ b/apps/cli/docs/supabase/config/validate.md @@ -0,0 +1,95 @@ +# supabase-config-validate + +Checks the project config file (`supabase/config.toml`, `config.json` or `config.jsonc`) and reports every problem in one pass, each with its line and a code frame. It never modifies a project file and never contacts the Supabase API. + +## What is checked + +1. The file parses (TOML or JSONC). +2. The schema version is one this CLI reads. +3. Only one config file applies: `config.json` and `config.jsonc` together is an error, and a JSON file next to `config.toml` is reported as shadowed. +4. The env files load, so `env(NAME)` references and `SUPABASE_*` overrides resolve. +5. Removed keys, unknown keys (with a `did you mean` suggestion when one is close), values of the wrong type, enum or range, and missing required keys. +6. The validations `supabase start` applies, such as a missing provider secret or an unreadable `content_path` file. +7. Every `[remotes.]` block, merged over the base config. Its problems carry the remote name. + +Stages 1 to 4 stop the run when they fail, because later checks cannot be computed; the report says where it stopped (`complete: false`, `stopped_at` in JSON). Everything from stage 5 on is reported together. + +## Secrets + +Values print as `****` unless the key is a known non-secret setting. `env(NAME)` references stay visible, and JSON output never contains source text or values. A value that comes from a `SUPABASE_*` variable is reported against its key, not a line. + +## `env()` references + +An unset `env(NAME)` is a warning when the section it sits in is enabled (the nearest `enabled` is not `false`), and the errors it would cause are not reported. Optional placeholders written by `supabase init` stay quiet. Warnings never change the exit code. + +## Two config files + +`config.json` and `config.jsonc` together is an error. `config.toml` next to a JSON file reports `shadowed_file`. + +## Example + +```text + ✗ db.major_version unsupported Postgres version + supabase/config.toml:9:17 + 8 │ [db] + > 9 │ major_version = 16 + │ ^^ + help: use one of 13, 14, 15, 17 + +✗ 1 error in supabase/config.toml +``` + +```json +{ + "schema_version": 1, + "valid": false, + "complete": true, + "file": "supabase/config.toml", + "format": "toml", + "remotes": [], + "errors": [ + { + "code": "invalid_value", + "severity": "error", + "key": "db.major_version", + "path": "db.major_version", + "reason": "unsupported Postgres version", + "help": "use one of 13, 14, 15, 17", + "line": 9, + "column": 17, + "end_line": 9, + "end_column": 19 + } + ], + "warnings": [], + "message": "supabase/config.toml has 1 problem" +} +``` + +Problem codes: `syntax`, `file_conflict`, `schema_version`, `removed_key`, `unknown_key`, `invalid_type`, `invalid_enum`, `invalid_value`, `missing_required`, `remote_project_id`, `env_file`, `removed_env`, `env_unresolved`, `deprecated_key`, `shadowed_file`, `unrecognized_schema`. Remotes that could not be checked are listed in `skipped_remotes`. Failures before a report exists (no config file, bad `--workdir`) use the generic `{"_tag":"Error",...}` envelope. + +The exit code is `0` when valid and `1` when there are errors. `-o/--output` is not supported; use `--output-format`. + +## CI and pre-commit + +```yaml +- run: supabase config validate +``` + +```yaml +repos: + - repo: local + hooks: + - id: supabase-config-validate + name: supabase config validate + entry: supabase config validate + language: system + files: ^supabase/config\.(toml|jsonc?)$ + pass_filenames: false +``` + +```sh +supabase config validate +supabase config validate --workdir path/to/project +supabase config validate --output-format json +``` diff --git a/apps/cli/docs/tracing-monitoring.md b/apps/cli/docs/tracing-monitoring.md index c42cee4b1e..4789856d1f 100644 --- a/apps/cli/docs/tracing-monitoring.md +++ b/apps/cli/docs/tracing-monitoring.md @@ -194,12 +194,12 @@ the timing ranges, API calls per command, and the slowest phases. It reports: The baseline API calls per workload on `develop` (a project-scoped command also refreshes the linked-project cache, so it makes two calls): -| Workload | API calls | -| ------------------------------------------ | ---------------------------------------------------------------------- | -| `version`, `help`, `init`, `migration-new` | 0 | -| `projects-list` | 1: `GET /v1/projects` | -| `gen-types` | 2: `GET /v1/projects/{ref}/types/typescript`, `GET /v1/projects/{ref}` | -| `functions-list` | 2: `GET /v1/projects/{ref}/functions`, `GET /v1/projects/{ref}` | +| Workload | API calls | +| ------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `version`, `help`, `init`, `migration-new`, `config-validate` | 0 | +| `projects-list` | 1: `GET /v1/projects` | +| `gen-types` | 2: `GET /v1/projects/{ref}/types/typescript`, `GET /v1/projects/{ref}` | +| `functions-list` | 2: `GET /v1/projects/{ref}/functions`, `GET /v1/projects/{ref}` | `SUPABASE_NO_KEYRING=1` disables the keyring (`CommandCredentials.load` reports `keyring.enabled=false`); the value `true` is not recognized. Counts come from the untimed traced diff --git a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts index 0a8ea60460..ef90498f08 100644 --- a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts @@ -9,7 +9,7 @@ import { withEnvVar, mockCommandSettings } from "../../tests/helpers/command-moc import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { readDbConfig } from "./db-config.read.ts"; import { resolveStorageCredentials } from "./storage-credentials.ts"; -import { resolveLocalConfigValues } from "./local-config-values.ts"; +import { collectLocalConfigIssues, resolveLocalConfigValues } from "./local-config-values.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; @@ -82,6 +82,28 @@ function failsWithL( }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])); } +/** Drives the collecting pipeline and asserts its first issue is the one the throwing pipeline fails with. */ +function collectsFirstWithL( + overrides: Record, + message: string, + document?: Readonly>, +) { + return Effect.gen(function* () { + const issues = yield* collectLocalConfigIssues( + baseConfig(overrides), + WORKDIR, + document, + undefined, + ); + const config = baseConfig(overrides); + const thrown = yield* Effect.flip( + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document), + ); + expect(issues[0]?.message).toContain(message); + expect(issues[0]?.message).toBe(thrown.message); + }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])); +} + interface ParityScenario { readonly name: string; readonly toml: ReadonlyArray; @@ -326,9 +348,17 @@ describe("validateResolvedConfig cross-caller parity (D vs L)", () => { Effect.gen(function* () { yield* failsWithD(scenario.toml, scenario.message); yield* failsWithL(scenario.overrides, scenario.message, scenario.document); + yield* collectsFirstWithL(scenario.overrides, scenario.message, scenario.document); }), ); } + + it.effect("a valid config collects no issues", () => + Effect.gen(function* () { + const issues = yield* collectLocalConfigIssues(baseConfig(), WORKDIR, undefined, undefined); + expect(issues).toEqual([]); + }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), + ); }); // The `api.port` branch is L-only in the table above (D has no api section) but is shared with diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index a525f82a81..b855a84b48 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -248,81 +248,196 @@ export function validateApiTlsPresence( certPath: string | undefined, keyPath: string | undefined, ): void { + const missing = missingApiTlsPath(certPath, keyPath); + if (missing !== undefined) { + throw new ConfigValidateError(`Missing required field in config: ${missing}`); + } +} + +/** The `api.tls` path that is unset while its counterpart is set, if any. */ +export function missingApiTlsPath( + certPath: string | undefined, + keyPath: string | undefined, +): "api.tls.cert_path" | "api.tls.key_path" | undefined { const hasCert = certPath !== undefined && certPath.length > 0; const hasKey = keyPath !== undefined && keyPath.length > 0; - if (hasCert && !hasKey) { - throw new ConfigValidateError("Missing required field in config: api.tls.key_path"); - } - if (hasKey && !hasCert) { - throw new ConfigValidateError("Missing required field in config: api.tls.cert_path"); + if (hasCert && !hasKey) return "api.tls.key_path"; + if (hasKey && !hasCert) return "api.tls.cert_path"; + return undefined; +} + +export type ConfigValidationIssueCode = + | "missing_required" + | "invalid_value" + | "invalid_type" + | "invalid_enum"; + +/** + * One validation failure and the config key path it belongs to. `message` is the legacy text the + * throwing wrappers raise; `reason` and `help` are value-free and meant for `config validate`. + */ +export interface ConfigValidationIssue { + readonly path: string; + readonly message: string; + readonly code: ConfigValidationIssueCode; + readonly reason: string; + readonly help?: string; +} + +const lastSegment = (path: string): string => path.split(".").at(-1) ?? path; + +const GATE_PATTERNS: ReadonlyArray string]> = [ + [/^auth\.email\.smtp\./, () => "auth.email.smtp.enabled"], + [/^auth\.captcha\./, () => "auth.captcha.enabled"], + [/^auth\.webauthn\./, () => "auth.passkey.enabled"], + [/^auth\.sms\.(\w+)\./, (match) => `auth.sms.${match[1]}.enabled`], + [/^auth\.external\.([^.]+)\./, (match) => `auth.external.${match[1]}.enabled`], + [/^auth\.third_party\.(\w+)\./, (match) => `auth.third_party.${match[1]}.enabled`], + [/^auth\.hook\.(\w+)\./, (match) => `auth.hook.${match[1]}.enabled`], + [/^analytics\./, () => "analytics.enabled"], + [/^api\.tls\./, () => "api.tls.enabled"], + [/^(api|studio|local_smtp)\.port$/, (match) => `${match[1]}.enabled`], +]; + +const gateOf = (path: string): string | undefined => { + for (const [pattern, gate] of GATE_PATTERNS) { + const match = pattern.exec(path); + if (match !== null) return gate(match); } + return undefined; +}; + +/** A required key that is empty or absent; the `enabled` switch that makes it required is inferred from the path. */ +export function missingIssue( + path: string, + message: string, + gate: string | undefined = gateOf(path), +): ConfigValidationIssue { + return gate === undefined + ? { + path, + message, + code: "missing_required", + reason: "missing required key", + help: `set ${path}`, + } + : { + path, + message, + code: "missing_required", + reason: `required when ${gate} = true`, + help: `set ${lastSegment(path)}, or set ${lastSegment(gate)} = false`, + }; } +export function invalidIssue( + path: string, + message: string, + reason: string, + help?: string, + code: ConfigValidationIssueCode = "invalid_value", +): ConfigValidationIssue { + return { path, message, code, reason, ...(help === undefined ? {} : { help }) }; +} + +const SUPPORTED_POSTGRES_MAJOR_VERSIONS: ReadonlyArray = [13, 14, 15, 17]; + /** - * Runs every validation branch this module owns, in established first-failure-wins order. + * Runs every validation branch this module owns and returns each failure in the established + * order, so the first issue is the one {@link validateResolvedConfig} throws. Independent checks + * all report; a check that depends on a failed earlier one in the same chain is skipped. * Pure — no I/O, no Effect. Callers own their own per-section I/O reads (signing keys, * `api.tls` cert/key, email template/notification content) at the correct position * themselves, using the pure helpers exported below. */ -export function validateResolvedConfig(input: ConfigValidationInput): void { +export function collectConfigValidationIssues( + input: ConfigValidationInput, +): ReadonlyArray { + const issues: Array = []; + const report = (issue: ConfigValidationIssue): void => { + issues.push(issue); + }; + const missing = (path: string, message: string): void => { + report(missingIssue(path, message)); + }; if (input.projectId !== undefined && input.projectId.length === 0) { - throw new ConfigValidateError("Missing required field in config: project_id"); + missing("project_id", "Missing required field in config: project_id"); } - // The actual cert/key file reads are caller-side I/O; this only checks the "exactly one of - // cert/key set" presence rule. if (input.api?.enabled) { - validateApiPort(input.api.enabled, input.api.port); - if (input.api.tls.enabled) { - validateApiTlsPresence(input.api.tls.certPath, input.api.tls.keyPath); + const api = input.api; + if (api.port === 0) missing("api.port", "Missing required field in config: api.port"); + if (api.tls.enabled) { + const absent = missingApiTlsPath(api.tls.certPath, api.tls.keyPath); + if (absent !== undefined) missing(absent, `Missing required field in config: ${absent}`); } } if (input.db.port === 0) { - throw new ConfigValidateError("Missing required field in config: db.port"); + missing("db.port", "Missing required field in config: db.port"); } if (input.db.majorVersion === 0) { - throw new ConfigValidateError("Missing required field in config: db.major_version"); - } - if (input.db.majorVersion === 12) { - throw new ConfigValidateError( - "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + missing("db.major_version", "Missing required field in config: db.major_version"); + } else if (input.db.majorVersion === 12) { + report( + invalidIssue( + "db.major_version", + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + "unsupported Postgres version", + "Postgres 12 is no longer supported; follow https://supabase.com/docs/guides/database#migrating-between-projects", + ), ); - } - if (![13, 14, 15, 17].includes(input.db.majorVersion)) { - throw new ConfigValidateError( - `Failed reading config: Invalid db.major_version: ${input.db.majorVersion}.`, + } else if (!SUPPORTED_POSTGRES_MAJOR_VERSIONS.includes(input.db.majorVersion)) { + report( + invalidIssue( + "db.major_version", + `Failed reading config: Invalid db.major_version: ${input.db.majorVersion}.`, + "unsupported Postgres version", + `use one of ${SUPPORTED_POSTGRES_MAJOR_VERSIONS.join(", ")}`, + ), ); } for (const name of input.storageBucketNames) { if (!BUCKET_NAME_PATTERN.test(name)) { - throw new ConfigValidateError( - `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${BUCKET_NAME_PATTERN.source})`, + report( + invalidIssue( + `storage.buckets.${name}`, + `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${BUCKET_NAME_PATTERN.source})`, + "invalid bucket name", + "use lowercase letters, numbers, dots, hyphens and spaces only", + ), ); } } if (input.studio?.enabled) { if (input.studio.port === 0) { - throw new ConfigValidateError("Missing required field in config: studio.port"); + missing("studio.port", "Missing required field in config: studio.port"); } try { parseUrl(input.studio.apiUrl); } catch (cause) { - throw new ConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); + report( + invalidIssue( + "studio.api_url", + `Invalid config for studio.api_url: ${messageOf(cause)}`, + "not a valid URL", + "use a full URL such as http://127.0.0.1:54321", + ), + ); } } if (input.localSmtp?.enabled && input.localSmtp.port === 0) { - throw new ConfigValidateError("Missing required field in config: local_smtp.port"); + missing("local_smtp.port", "Missing required field in config: local_smtp.port"); } if (input.auth !== undefined) { const auth = input.auth; if (auth.siteUrl.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.site_url"); + missing("auth.site_url", "Missing required field in config: auth.site_url"); } // The provider enum check runs before the `enabled` check since it's conceptually a @@ -335,16 +450,24 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { provider !== "hcaptcha" && provider !== "turnstile" ) { - throw new ConfigValidateError( - "failed to parse config:\nauth.captcha.provider: must be one of hcaptcha, turnstile", + report( + invalidIssue( + "auth.captcha.provider", + "failed to parse config:\nauth.captcha.provider: must be one of hcaptcha, turnstile", + "expected one of hcaptcha, turnstile", + "use hcaptcha or turnstile", + "invalid_enum", + ), ); - } - if (auth.captcha.enabled) { + } else if (auth.captcha.enabled) { if (auth.captcha.provider === undefined) { - throw new ConfigValidateError("Missing required field in config: auth.captcha.provider"); + missing( + "auth.captcha.provider", + "Missing required field in config: auth.captcha.provider", + ); } if (auth.captcha.secret === undefined || auth.captcha.secret.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.captcha.secret"); + missing("auth.captcha.secret", "Missing required field in config: auth.captcha.secret"); } } } @@ -354,24 +477,32 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { // Caller only builds `passkey` when `[auth.passkey] enabled` is true. if (auth.passkey !== undefined) { if (!auth.passkey.webauthnPresent) { - throw new ConfigValidateError( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - } - if (auth.passkey.rpId === undefined || auth.passkey.rpId.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.webauthn.rp_id"); - } - if (auth.passkey.rpOrigins === undefined || auth.passkey.rpOrigins.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.webauthn.rp_origins"); + report({ + ...missingIssue("auth.webauthn", "", "auth.passkey.enabled"), + message: + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + reason: "required when auth.passkey.enabled = true", + help: "add an [auth.webauthn] section, or set auth.passkey.enabled = false", + }); + } else { + if (auth.passkey.rpId === undefined || auth.passkey.rpId.length === 0) { + missing("auth.webauthn.rp_id", "Missing required field in config: auth.webauthn.rp_id"); + } + if (auth.passkey.rpOrigins === undefined || auth.passkey.rpOrigins.length === 0) { + missing( + "auth.webauthn.rp_origins", + "Missing required field in config: auth.webauthn.rp_origins", + ); + } } } // Caller pre-filters to enabled-only hooks, pre-ordered by the fixed hook-type sequence. for (const hook of auth.hooks) { + const hookPath = `auth.hook.${hook.type}`; if (hook.uri.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.hook.${hook.type}.uri`, - ); + missing(`${hookPath}.uri`, `Missing required field in config: auth.hook.${hook.type}.uri`); + continue; } // Uses `parseUrl`'s stricter semantics (the same port used for `studio.api_url` // above) so a malformed URI like an unterminated IPv6 host (`http://[::1`) fails the @@ -380,38 +511,64 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { try { scheme = parseUrl(hook.uri).scheme; } catch (cause) { - throw new ConfigValidateError(`failed to parse template url: ${messageOf(cause)}`); + report( + invalidIssue( + `${hookPath}.uri`, + `failed to parse template url: ${messageOf(cause)}`, + "not a valid URI", + "use an http(s):// or pg-functions:// URI", + ), + ); + continue; } if (scheme === "http" || scheme === "https") { if (hook.secrets.length === 0) { - throw new ConfigValidateError( + missing( + `${hookPath}.secrets`, `Missing required field in config: auth.hook.${hook.type}.secrets`, ); - } - for (const secret of hook.secrets.split("|")) { - if (!HOOK_SECRET_PATTERN.test(secret)) { - throw new ConfigValidateError( + } else if (!hook.secrets.split("|").every((secret) => HOOK_SECRET_PATTERN.test(secret))) { + report( + invalidIssue( + `${hookPath}.secrets`, `Invalid hook config: auth.hook.${hook.type}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, - ); - } + "not a valid webhook secret", + 'format it as "v1,whsec_" with at least 32 characters; separate several with |', + ), + ); } } else if (scheme === "pg-functions") { if (hook.secrets.length > 0) { - throw new ConfigValidateError( - `Invalid hook config: auth.hook.${hook.type}.secrets is unsupported for pg-functions URI`, + report( + invalidIssue( + `${hookPath}.secrets`, + `Invalid hook config: auth.hook.${hook.type}.secrets is unsupported for pg-functions URI`, + "not supported for a pg-functions URI", + "remove secrets, or use an http(s) URI", + ), ); } } else { - throw new ConfigValidateError( - `Invalid hook config: auth.hook.${hook.type}.uri should be a HTTP, HTTPS, or pg-functions URI`, + report( + invalidIssue( + `${hookPath}.uri`, + `Invalid hook config: auth.hook.${hook.type}.uri should be a HTTP, HTTPS, or pg-functions URI`, + "unsupported URI scheme", + "use an http(s):// or pg-functions:// URI", + ), ); } } for (const factor of auth.mfa) { if (factor.enrollEnabled && !factor.verifyEnabled) { - throw new ConfigValidateError( - `Invalid MFA config: auth.mfa.${factor.label}.enroll_enabled requires verify_enabled`, + report( + invalidIssue( + `auth.mfa.${factor.label}.enroll_enabled`, + `Invalid MFA config: auth.mfa.${factor.label}.enroll_enabled requires verify_enabled`, + "requires verify_enabled = true", + `set auth.mfa.${factor.label}.verify_enabled = true, or disable enroll_enabled`, + ), ); } } @@ -420,22 +577,20 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { // resolveEmailTemplateContentPath below. if (auth.smtp !== undefined && auth.smtp.enabled) { - if (auth.smtp.host.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.email.smtp.host"); - } - if (auth.smtp.port === 0) { - throw new ConfigValidateError("Missing required field in config: auth.email.smtp.port"); - } - if (auth.smtp.user.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.email.smtp.user"); - } - if (auth.smtp.pass.length === 0) { - throw new ConfigValidateError("Missing required field in config: auth.email.smtp.pass"); - } - if (auth.smtp.adminEmail.length === 0) { - throw new ConfigValidateError( - "Missing required field in config: auth.email.smtp.admin_email", - ); + const smtpFields: ReadonlyArray = [ + ["host", auth.smtp.host.length === 0], + ["port", auth.smtp.port === 0], + ["user", auth.smtp.user.length === 0], + ["pass", auth.smtp.pass.length === 0], + ["admin_email", auth.smtp.adminEmail.length === 0], + ]; + for (const [field, isMissing] of smtpFields) { + if (isMissing) { + missing( + `auth.email.smtp.${field}`, + `Missing required field in config: auth.email.smtp.${field}`, + ); + } } } @@ -443,83 +598,94 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { // workos. Each provider's required fields are checked as encountered; "more than one // enabled" is checked only after every entry validates individually. for (const thirdParty of auth.thirdParty) { + const base = `auth.third_party.${thirdParty.provider === "cognito" ? "aws_cognito" : thirdParty.provider}`; + const missingMessage = (field: string) => + `Invalid config: auth.third_party.${thirdParty.provider} is enabled but without a ${field}.`; switch (thirdParty.provider) { case "firebase": { if (thirdParty.requiredField.length === 0) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); + missing(`${base}.project_id`, missingMessage("project_id")); } break; } case "auth0": { if (thirdParty.requiredField.length === 0) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", - ); + missing(`${base}.tenant`, missingMessage("tenant")); } break; } case "cognito": { if (thirdParty.requiredField.length === 0) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - } - if ( + missing(`${base}.user_pool_id`, missingMessage("user_pool_id")); + } else if ( thirdParty.cognitoUserPoolRegion === undefined || thirdParty.cognitoUserPoolRegion.length === 0 ) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); + missing(`${base}.user_pool_region`, missingMessage("user_pool_region")); } break; } case "clerk": { if (thirdParty.requiredField.length === 0) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.clerk is enabled but without a domain.", - ); - } - if (!CLERK_DOMAIN_PATTERN.test(thirdParty.requiredField)) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + missing(`${base}.domain`, missingMessage("domain")); + } else if (!CLERK_DOMAIN_PATTERN.test(thirdParty.requiredField)) { + report( + invalidIssue( + `${base}.domain`, + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + "not a Clerk domain", + "use a domain like clerk.example.com or example.clerk.accounts.dev; see https://clerk.com/setup/supabase", + ), ); } break; } case "workos": { if (thirdParty.requiredField.length === 0) { - throw new ConfigValidateError( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", - ); + missing(`${base}.issuer_url`, missingMessage("issuer_url")); } break; } } } if (auth.thirdParty.length > 1) { - throw new ConfigValidateError( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", + report( + invalidIssue( + "auth.third_party", + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + "more than one third-party provider is enabled", + "enable one auth.third_party provider and set enabled = false on the others", + ), ); } } for (const slug of input.functionSlugs) { if (!FUNCTION_SLUG_PATTERN.test(slug)) { - throw new ConfigValidateError( - `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${FUNCTION_SLUG_PATTERN.source})`, + report( + invalidIssue( + `functions.${slug}`, + `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${FUNCTION_SLUG_PATTERN.source})`, + "invalid function name", + "start with a letter; use letters, digits, underscores and hyphens only", + ), ); } } if (input.edgeRuntimeDenoVersion === 0) { - throw new ConfigValidateError("Missing required field in config: edge_runtime.deno_version"); - } - if (input.edgeRuntimeDenoVersion !== 1 && input.edgeRuntimeDenoVersion !== 2) { - throw new ConfigValidateError( - `Failed reading config: Invalid edge_runtime.deno_version: ${input.edgeRuntimeDenoVersion}.`, + missing( + "edge_runtime.deno_version", + "Missing required field in config: edge_runtime.deno_version", + ); + } else if (input.edgeRuntimeDenoVersion !== 1 && input.edgeRuntimeDenoVersion !== 2) { + report( + invalidIssue( + "edge_runtime.deno_version", + `Failed reading config: Invalid edge_runtime.deno_version: ${input.edgeRuntimeDenoVersion}.`, + "unsupported Deno version", + "use 1 or 2", + ), ); } @@ -532,39 +698,69 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { backend !== "postgres" && backend !== "bigquery" ) { - throw new ConfigValidateError( - "failed to parse config:\nanalytics.backend: must be one of postgres, bigquery", + report( + invalidIssue( + "analytics.backend", + "failed to parse config:\nanalytics.backend: must be one of postgres, bigquery", + "expected one of postgres, bigquery", + "use postgres or bigquery", + "invalid_enum", + ), ); - } - if (input.analytics.enabled && backend === "bigquery") { + } else if (input.analytics.enabled && backend === "bigquery") { if (input.analytics.gcpProjectId.length === 0) { - throw new ConfigValidateError("Missing required field in config: analytics.gcp_project_id"); + missing( + "analytics.gcp_project_id", + "Missing required field in config: analytics.gcp_project_id", + ); } if (input.analytics.gcpProjectNumber.length === 0) { - throw new ConfigValidateError( + missing( + "analytics.gcp_project_number", "Missing required field in config: analytics.gcp_project_number", ); } if (input.analytics.gcpJwtPath.length === 0) { - throw new ConfigValidateError( - `Path to GCP Service Account Key must be provided in config, relative to ${input.configFileName ?? "config.toml"}: analytics.gcp_jwt_path`, - ); + report({ + ...missingIssue("analytics.gcp_jwt_path", "", "analytics.enabled"), + message: `Path to GCP Service Account Key must be provided in config, relative to ${input.configFileName ?? "config.toml"}: analytics.gcp_jwt_path`, + reason: "required when analytics.backend = bigquery", + help: "set gcp_jwt_path to the service account key file, relative to supabase/", + }); } } if (input.experimental.webhooksPresent === true && input.experimental.webhooksEnabled !== true) { - throw new ConfigValidateError( - "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + report( + invalidIssue( + "experimental.webhooks.enabled", + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + "webhooks cannot be deactivated", + "set enabled = true, or remove [experimental.webhooks]", + ), ); } if ( input.experimental.pgdeltaFormatOptions !== "" && !isValidJson(input.experimental.pgdeltaFormatOptions) ) { - throw new ConfigValidateError( - "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + report( + invalidIssue( + "experimental.pgdelta.format_options", + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + "not valid JSON", + "use a JSON object string", + ), ); } + + return issues; +} + +/** Throws the first issue {@link collectConfigValidationIssues} reports, in established first-failure-wins order. */ +export function validateResolvedConfig(input: ConfigValidationInput): void { + const [first] = collectConfigValidationIssues(input); + if (first !== undefined) throw new ConfigValidateError(first.message); } function isValidJson(value: string): boolean { @@ -739,61 +935,84 @@ export function apiTlsKeyReadErrorMessage(cause: unknown): string { } /** - * Validates only the first enabled provider in the fixed priority order; a later - * enabled-but-incomplete provider is never checked. + * Reports required-field failures for the first enabled provider in the fixed priority order; a + * later enabled-but-incomplete provider is never checked. */ -export function validateAuthSmsProviders(sms: CliConfig["auth"]["sms"]): void { - function requireField(provider: string, field: string, value: string | undefined): void { - if (value === undefined || value.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.sms.${provider}.${field}`, - ); +export function collectAuthSmsProviderIssues( + sms: CliConfig["auth"]["sms"], +): ReadonlyArray { + const issues: Array = []; + const requireFields = ( + provider: string, + fields: ReadonlyArray, + ): void => { + for (const [field, value] of fields) { + if (value === undefined || value.length === 0) { + issues.push( + missingIssue( + `auth.sms.${provider}.${field}`, + `Missing required field in config: auth.sms.${provider}.${field}`, + ), + ); + } } - } + }; if (sms.twilio.enabled) { - requireField("twilio", "account_sid", sms.twilio.account_sid); - requireField("twilio", "message_service_sid", sms.twilio.message_service_sid); - requireField("twilio", "auth_token", sms.twilio.auth_token); - return; - } - if (sms.twilio_verify.enabled) { - requireField("twilio_verify", "account_sid", sms.twilio_verify.account_sid); - requireField("twilio_verify", "message_service_sid", sms.twilio_verify.message_service_sid); - requireField("twilio_verify", "auth_token", sms.twilio_verify.auth_token); - return; - } - if (sms.messagebird.enabled) { - requireField("messagebird", "originator", sms.messagebird.originator); - requireField("messagebird", "access_key", sms.messagebird.access_key); - return; - } - if (sms.textlocal.enabled) { - requireField("textlocal", "sender", sms.textlocal.sender); - requireField("textlocal", "api_key", sms.textlocal.api_key); - return; - } - if (sms.vonage.enabled) { - requireField("vonage", "from", sms.vonage.from); - requireField("vonage", "api_key", sms.vonage.api_key); - requireField("vonage", "api_secret", sms.vonage.api_secret); - return; + requireFields("twilio", [ + ["account_sid", sms.twilio.account_sid], + ["message_service_sid", sms.twilio.message_service_sid], + ["auth_token", sms.twilio.auth_token], + ]); + } else if (sms.twilio_verify.enabled) { + requireFields("twilio_verify", [ + ["account_sid", sms.twilio_verify.account_sid], + ["message_service_sid", sms.twilio_verify.message_service_sid], + ["auth_token", sms.twilio_verify.auth_token], + ]); + } else if (sms.messagebird.enabled) { + requireFields("messagebird", [ + ["originator", sms.messagebird.originator], + ["access_key", sms.messagebird.access_key], + ]); + } else if (sms.textlocal.enabled) { + requireFields("textlocal", [ + ["sender", sms.textlocal.sender], + ["api_key", sms.textlocal.api_key], + ]); + } else if (sms.vonage.enabled) { + requireFields("vonage", [ + ["from", sms.vonage.from], + ["api_key", sms.vonage.api_key], + ["api_secret", sms.vonage.api_secret], + ]); } + return issues; +} + +/** Throws the first issue {@link collectAuthSmsProviderIssues} reports. */ +export function validateAuthSmsProviders(sms: CliConfig["auth"]["sms"]): void { + const [first] = collectAuthSmsProviderIssues(sms); + if (first !== undefined) throw new ConfigValidateError(first.message); } /** - * Validates required fields for every enabled `auth.external.` provider, including + * Reports required fields for every enabled `auth.external.` provider, including * custom names `@supabase/config`'s schema drops at decode time, so `resolved` must come from * `resolveAuthExternalProviders` over the effective document rather than the decoded config. */ -export function validateAuthExternalProviders( +export function collectAuthExternalProviderIssues( resolved: Readonly>, -): void { +): ReadonlyArray { + const issues: Array = []; for (const [name, provider] of Object.entries(resolved)) { if (!provider.enabled) continue; if (provider.clientId.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.external.${name}.client_id`, + issues.push( + missingIssue( + `auth.external.${name}.client_id`, + `Missing required field in config: auth.external.${name}.client_id`, + ), ); } if ( @@ -801,9 +1020,21 @@ export function validateAuthExternalProviders( name !== "google" && (provider.secret === undefined || provider.secret.length === 0) ) { - throw new ConfigValidateError( - `Missing required field in config: auth.external.${name}.secret`, + issues.push( + missingIssue( + `auth.external.${name}.secret`, + `Missing required field in config: auth.external.${name}.secret`, + ), ); } } + return issues; +} + +/** Throws the first issue {@link collectAuthExternalProviderIssues} reports. */ +export function validateAuthExternalProviders( + resolved: Readonly>, +): void { + const [first] = collectAuthExternalProviderIssues(resolved); + if (first !== undefined) throw new ConfigValidateError(first.message); } diff --git a/apps/cli/src/command-internal/config-validate.unit.test.ts b/apps/cli/src/command-internal/config-validate.unit.test.ts index 405a13d0e3..742413b680 100644 --- a/apps/cli/src/command-internal/config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.unit.test.ts @@ -12,11 +12,18 @@ import { ConfigValidateError, type AuthInput, type ConfigValidationInput, + collectAuthExternalProviderIssues, + collectAuthSmsProviderIssues, + collectConfigValidationIssues, resolveEmailTemplateContentPath, validateResolvedConfig, } from "./config-validate.ts"; +import { CliConfigSchema } from "@supabase/config"; +import { Schema } from "effect"; import { parseBoolLiteral } from "../shared/config/config-bool.ts"; +const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); + describe("parseBoolLiteral", () => { it("accepts the true forms", () => { for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { @@ -1394,3 +1401,141 @@ describe("validateResolvedConfig", () => { }); }); }); + +describe("collectConfigValidationIssues", () => { + it("reports independent failures together, in validation order", () => { + const issues = collectConfigValidationIssues( + minimalInput({ + api: { enabled: true, port: 0, tls: { enabled: false, certPath: "", keyPath: "" } }, + studio: { enabled: true, port: 0, apiUrl: "http://localhost:54321" }, + auth: minimalAuthInput({ siteUrl: "" }), + }), + ); + + expect(issues.map((issue) => issue.path)).toEqual(["api.port", "studio.port", "auth.site_url"]); + }); + + it("reports nothing for a valid input", () => { + expect(collectConfigValidationIssues(minimalInput())).toEqual([]); + }); + + it("stops the db.major_version chain at its first failure", () => { + const issues = collectConfigValidationIssues( + minimalInput({ db: { port: 5432, majorVersion: 12 } }), + ); + + expect(issues).toHaveLength(1); + expect(issues[0]?.path).toBe("db.major_version"); + }); + + it("does not check a hook's secrets when its uri is malformed", () => { + const issues = collectConfigValidationIssues( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "send_sms", uri: "http://[::1", secrets: "bad" }], + }), + }), + ); + + expect(issues.map((issue) => issue.path)).toEqual(["auth.hook.send_sms.uri"]); + }); + + it("checks each hook independently", () => { + const issues = collectConfigValidationIssues( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { type: "send_sms", uri: "", secrets: "" }, + { type: "send_email", uri: "https://example.test/hook", secrets: "bad" }, + ], + }), + }), + ); + + expect(issues.map((issue) => issue.path)).toEqual([ + "auth.hook.send_sms.uri", + "auth.hook.send_email.secrets", + ]); + }); + + it("reports cognito's region only once its user pool id is present", () => { + const [missingId] = collectConfigValidationIssues( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "cognito", requiredField: "" }] }), + }), + ); + const [missingRegion] = collectConfigValidationIssues( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "cognito", requiredField: "pool" }] }), + }), + ); + + expect(missingId?.path).toBe("auth.third_party.aws_cognito.user_pool_id"); + expect(missingRegion?.path).toBe("auth.third_party.aws_cognito.user_pool_region"); + }); + + it("reports each incomplete provider and the one-provider rule", () => { + const issues = collectConfigValidationIssues( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "firebase", requiredField: "" }, + { provider: "auth0", requiredField: "" }, + ], + }), + }), + ); + + expect(issues.map((issue) => issue.path)).toEqual([ + "auth.third_party.firebase.project_id", + "auth.third_party.auth0.tenant", + "auth.third_party", + ]); + }); + + it("keeps the throwing wrapper's first message identical to the first collected issue", () => { + const input = minimalInput({ db: { port: 0, majorVersion: 16 } }); + const [first] = collectConfigValidationIssues(input); + + expect(() => validateResolvedConfig(input)).toThrow(first?.message ?? ""); + }); +}); + +describe("collectAuthSmsProviderIssues", () => { + it("lists every missing field of the first enabled provider only", () => { + const { sms } = decodeCliConfig({}).auth; + const issues = collectAuthSmsProviderIssues({ + ...sms, + twilio: { ...sms.twilio, enabled: true, account_sid: "", message_service_sid: "" }, + vonage: { ...sms.vonage, enabled: true }, + }); + + expect(issues.map((issue) => issue.path)).toEqual([ + "auth.sms.twilio.account_sid", + "auth.sms.twilio.message_service_sid", + "auth.sms.twilio.auth_token", + ]); + }); +}); + +describe("collectAuthExternalProviderIssues", () => { + it("reports a missing client id and secret for each enabled provider", () => { + const issues = collectAuthExternalProviderIssues({ + github: { enabled: true, clientId: "", url: "", skipNonceCheck: false, emailOptional: false }, + apple: { enabled: true, clientId: "", url: "", skipNonceCheck: false, emailOptional: false }, + gitlab: { + enabled: false, + clientId: "", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }); + + expect(issues.map((issue) => issue.path)).toEqual([ + "auth.external.github.client_id", + "auth.external.github.secret", + "auth.external.apple.client_id", + ]); + }); +}); diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index 740e870a03..e7d9eeb3b9 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -37,8 +37,14 @@ import { type ApiInput, type AuthInput, type CaptchaInput, + collectAuthExternalProviderIssues, + collectAuthSmsProviderIssues, + collectConfigValidationIssues, ConfigValidateError, type ConfigValidationInput, + type ConfigValidationIssue, + invalidIssue, + missingApiTlsPath, type DbInput, emailContentPathReadErrorMessage, type ExperimentalInput, @@ -173,6 +179,42 @@ const thrownError = (cause: unknown) => const attempt = (thunk: () => A) => Effect.try({ try: thunk, catch: thrownError }); +interface IssueMeta { + readonly reason: string; + readonly help?: string; + readonly code?: ConfigValidationIssue["code"]; +} + +const issueFrom = (path: string, message: string, meta: IssueMeta): ConfigValidationIssue => + invalidIssue(path, message, meta.reason, meta.help, meta.code); + +const SIGNED_KEY_ISSUE: IssueMeta = { + reason: "could not be generated", + help: "set the key explicitly, or fix the signing keys configuration", +}; + +const CONTENT_PATH_ISSUE: IssueMeta = { + reason: "content_path is not a readable file inside the project", + help: "point content_path at an existing file under the project root, or use content_path instead of content", +}; + +/** Without a collector `effect` runs unchanged; with one a failure is recorded at `path` and `fallback` stands in. */ +const collecting = ( + collector: Array | undefined, + path: string, + effect: Effect.Effect, + fallback: A, + meta: IssueMeta, +): Effect.Effect => + collector === undefined + ? effect + : effect.pipe( + Effect.catch((error: E) => { + collector.push(issueFrom(path, error.message, meta)); + return Effect.succeed(fallback); + }), + ); + /** Narrows a configured string to one of `allowed`, failing with the codec wording otherwise. */ export const narrowConfigEnum = ( path: string, @@ -184,6 +226,7 @@ export const narrowConfigEnum = ( throw new CliConfigValueError({ path, tier: "config", + expected: `one of ${allowed.join(", ")}`, message: `Invalid config for ${path}: "${configured}" must be one of ${allowed .map((value) => `"${value}"`) .join(", ")}`, @@ -347,20 +390,27 @@ const readApiTlsFiles = Effect.fnUntraced(function* ( if (certPath === undefined || certPath.length === 0) return; if (keyPath === undefined || keyPath.length === 0) return; + yield* readApiTlsFile("cert", workdir, certPath); + yield* readApiTlsFile("key", workdir, keyPath); +}); + +const readApiTlsFile = Effect.fnUntraced(function* ( + kind: "cert" | "key", + workdir: string, + configured: string, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs - .readFile(resolveApiTlsPath(path, workdir, certPath)) - .pipe( - Effect.mapError( - (cause) => new ConfigValidateError(apiTlsCertReadErrorMessage(fsErrorCause(cause))), - ), - ); - yield* fs - .readFile(resolveApiTlsPath(path, workdir, keyPath)) + .readFile(resolveApiTlsPath(path, workdir, configured)) .pipe( Effect.mapError( - (cause) => new ConfigValidateError(apiTlsKeyReadErrorMessage(fsErrorCause(cause))), + (cause) => + new ConfigValidateError( + kind === "cert" + ? apiTlsCertReadErrorMessage(fsErrorCause(cause)) + : apiTlsKeyReadErrorMessage(fsErrorCause(cause)), + ), ), ); }); @@ -439,56 +489,56 @@ export function resolveAuthEmail( const readAuthEmailTemplateContent = Effect.fnUntraced(function* ( email: ResolvedAuthEmail, workdir: string, + collector?: Array, ) { const fs = yield* FileSystem.FileSystem; - for (const [name, tmpl] of Object.entries(email.template)) { - const path = yield* resolveEmailTemplateContentPath({ - section: "template", - name, - contentPath: tmpl.content_path, - contentPresent: tmpl.content_present, - base: workdir, - }).pipe( - Effect.mapError((cause) => - cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), - ), - ); - if (path === undefined) continue; - yield* fs - .readFile(path) - .pipe( - Effect.mapError( - (cause) => - new ConfigValidateError( - emailContentPathReadErrorMessage("template", name, fsErrorCause(cause)), - ), + const readEntry = ( + section: "template" | "notification", + name: string, + tmpl: ResolvedAuthEmailTemplate, + ) => + Effect.gen(function* () { + const path = yield* resolveEmailTemplateContentPath({ + section, + name, + contentPath: tmpl.content_path, + contentPresent: tmpl.content_present, + base: workdir, + }).pipe( + Effect.mapError((cause) => + cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), ), ); + if (path === undefined) return; + yield* fs + .readFile(path) + .pipe( + Effect.mapError( + (cause) => + new ConfigValidateError( + emailContentPathReadErrorMessage(section, name, fsErrorCause(cause)), + ), + ), + ); + }); + for (const [name, tmpl] of Object.entries(email.template)) { + yield* collecting( + collector, + `auth.email.template.${name}.content_path`, + readEntry("template", name, tmpl), + undefined, + CONTENT_PATH_ISSUE, + ); } for (const [name, tmpl] of Object.entries(email.notification)) { if (!tmpl.enabled) continue; - const path = yield* resolveEmailTemplateContentPath({ - section: "notification", - name, - contentPath: tmpl.content_path, - contentPresent: tmpl.content_present, - base: workdir, - }).pipe( - Effect.mapError((cause) => - cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), - ), + yield* collecting( + collector, + `auth.email.notification.${name}.content_path`, + readEntry("notification", name, tmpl), + undefined, + CONTENT_PATH_ISSUE, ); - if (path === undefined) continue; - yield* fs - .readFile(path) - .pipe( - Effect.mapError( - (cause) => - new ConfigValidateError( - emailContentPathReadErrorMessage("notification", name, fsErrorCause(cause)), - ), - ), - ); } }); @@ -670,6 +720,8 @@ export function resolveAuthSms(sms: CliConfig["auth"]["sms"]): CliConfig["auth"] }; } +type ValueOf = T[keyof T]; + /** Matches `GotrueExternalProviderInput`'s fields, kept separate to avoid a command-specific import. */ export interface ResolvedAuthExternalProvider { readonly enabled: boolean; @@ -698,6 +750,7 @@ export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boole throw new CliConfigValueError({ path: dottedFieldPath, tier: "config", + expected: "a boolean", message: `Invalid config for ${dottedFieldPath}: "${raw}" is not a valid boolean`, }); } @@ -709,6 +762,28 @@ function strToArr(value: string): Array { return value.length === 0 ? [] : value.split(","); } +function resolveExternalProvider( + name: string, + provider: ValueOf | undefined, + rawProvider: Readonly> | undefined, +): ResolvedAuthExternalProvider { + return { + enabled: + provider?.enabled ?? + rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`), + clientId: provider?.client_id ?? asString(rawProvider?.["client_id"]) ?? "", + secret: provider?.secret ?? asString(rawProvider?.["secret"]), + url: provider?.url ?? asString(rawProvider?.["url"]) ?? "", + redirectUri: provider?.redirect_uri ?? asString(rawProvider?.["redirect_uri"]), + skipNonceCheck: + provider?.skip_nonce_check ?? + rawUnmodeledBool(rawProvider?.["skip_nonce_check"], `auth.external.${name}.skip_nonce_check`), + emailOptional: + provider?.email_optional ?? + rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`), + }; +} + /** * Resolves `auth.external.` entries, iterating the effective document's provider names * (not just the schema's fixed ~19) since custom providers decode with no schema at all. @@ -718,6 +793,7 @@ function strToArr(value: string): Array { export function resolveAuthExternalProviders( authDocument: Readonly> | undefined, external: CliConfig["auth"]["external"], + failures?: Array, ): Record { const externalDoc = asRecord(authDocument?.["external"]); @@ -728,24 +804,23 @@ export function resolveAuthExternalProviders( const provider = decodedProviders.get(name); const rawProvider = provider === undefined ? asRecord(externalDoc?.[name]) : undefined; if (provider === undefined && rawProvider === undefined) continue; - result[name] = { - enabled: - provider?.enabled ?? - rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`), - clientId: provider?.client_id ?? asString(rawProvider?.["client_id"]) ?? "", - secret: provider?.secret ?? asString(rawProvider?.["secret"]), - url: provider?.url ?? asString(rawProvider?.["url"]) ?? "", - redirectUri: provider?.redirect_uri ?? asString(rawProvider?.["redirect_uri"]), - skipNonceCheck: - provider?.skip_nonce_check ?? - rawUnmodeledBool( - rawProvider?.["skip_nonce_check"], - `auth.external.${name}.skip_nonce_check`, + if (failures === undefined) { + result[name] = resolveExternalProvider(name, provider, rawProvider); + continue; + } + try { + result[name] = resolveExternalProvider(name, provider, rawProvider); + } catch (cause) { + failures.push( + invalidIssue( + `auth.external.${name}`, + cause instanceof Error ? cause.message : String(cause), + "expected booleans for enabled, skip_nonce_check and email_optional", + "use true or false", + "invalid_type", ), - emailOptional: - provider?.email_optional ?? - rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`), - }; + ); + } } return result; } @@ -765,7 +840,7 @@ export function resolveAuthExternalProviders( * @throws {ConfigValidateError} for every other validation branch, deferred to a single call * to {@link validateResolvedConfig} at the end of this function. */ -export const resolveLocalConfigValues = Effect.fnUntraced(function* ( +export const resolveLocalConfigValues = ( config: CliConfig, hostname: string, workdir: string, @@ -776,6 +851,40 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( document?: Readonly>, /** The loaded config file's path, so messages name it; `undefined` reads as `config.toml`. */ configPath?: string, +) => resolveLocalConfigValuesWith(config, hostname, workdir, document, configPath); + +/** Reports every config validation and config-file read failure instead of stopping at the first. */ +export const collectLocalConfigIssues = Effect.fn("LocalConfig.collectIssues")(function* ( + config: CliConfig, + workdir: string, + document: Readonly> | undefined, + configPath: string | undefined, +) { + const issues: Array = []; + yield* resolveLocalConfigValuesWith( + config, + "127.0.0.1", + workdir, + document, + configPath, + issues, + ).pipe( + Effect.catch((error: { readonly message: string }) => + Effect.sync(() => { + issues.push(invalidIssue("", error.message, "could not be checked")); + }), + ), + ); + return issues; +}); + +const resolveLocalConfigValuesWith = Effect.fnUntraced(function* ( + config: CliConfig, + hostname: string, + workdir: string, + document: Readonly> | undefined, + configPath: string | undefined, + collector?: Array, ) { const resolvedProjectId = config.project_id ?? ""; @@ -784,29 +893,63 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( const apiTlsCertPath = config.api.tls.cert_path; const apiTlsKeyPath = config.api.tls.key_path; if (apiEnabled && apiTlsEnabled) { - yield* readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + if (collector === undefined) { + yield* readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + } else if (missingApiTlsPath(apiTlsCertPath, apiTlsKeyPath) === undefined) { + for (const [kind, key, configured] of [ + ["cert", "api.tls.cert_path", apiTlsCertPath], + ["key", "api.tls.key_path", apiTlsKeyPath], + ] as const) { + if (configured === undefined || configured.length === 0) continue; + yield* collecting(collector, key, readApiTlsFile(kind, workdir, configured), undefined, { + reason: `${kind === "cert" ? "certificate" : "private key"} file is not readable`, + help: "point it at an existing file, relative to the supabase/ directory", + }); + } + } } const apiPort = config.api.port; - const apiExternalUrl = yield* attempt(() => - resolveApiExternalUrl( - { - external_url: config.api.external_url, - port: apiPort, - tls: { enabled: apiTlsEnabled }, - }, - hostname, + const apiExternalUrl = yield* collecting( + collector, + "api.external_url", + attempt(() => + resolveApiExternalUrl( + { + external_url: config.api.external_url, + port: apiPort, + tls: { enabled: apiTlsEnabled }, + }, + hostname, + ), ), + "", + { + reason: "not a valid URL", + help: "use a full URL such as https://api.example.com", + }, ); const dbPort = config.db.port; const majorVersion = config.db.major_version; // `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's read off the document. // The resolved value is written verbatim into `/etc/postgresql-custom/pgsodium_root.key`. const rawRootKey = asRecord(document?.["db"])?.["root_key"]; + const rootKeyMessage = "failed to parse config:\ndb.root_key: expected a table"; if (rawRootKey !== undefined && typeof rawRootKey !== "string") { - return yield* new ConfigValidateError("failed to parse config:\ndb.root_key: expected a table"); + if (collector === undefined) return yield* new ConfigValidateError(rootKeyMessage); + collector.push( + invalidIssue( + "db.root_key", + rootKeyMessage, + "expected a string", + "set root_key to a string", + "invalid_type", + ), + ); } const rootKey = - rawRootKey === undefined || rawRootKey.length === 0 ? POSTGRES_DEFAULT_ROOT_KEY : rawRootKey; + typeof rawRootKey !== "string" || rawRootKey.length === 0 + ? POSTGRES_DEFAULT_ROOT_KEY + : rawRootKey; const storageBucketNames = config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; const studioEnabled = config.studio.enabled; @@ -814,7 +957,16 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( const studioApiUrl = config.studio.api_url; const mailpitEnabled = config.local_smtp.enabled; const mailpitPort = config.local_smtp.port; - const jwtSecret = yield* attempt(() => resolveJwtSecret(config.auth.jwt_secret)); + const jwtSecret = yield* collecting( + collector, + "auth.jwt_secret", + attempt(() => resolveJwtSecret(config.auth.jwt_secret)), + defaultJwtSecret, + { + reason: "too short", + help: `use at least ${MIN_JWT_SECRET_LENGTH} characters`, + }, + ); const signingKeysPath = config.auth.signing_keys_path; // The signing-keys file read only runs when auth is enabled, so a disabled auth section // never opens/parses `signing_keys_path`, even a stale or missing one. JWT-secret validation @@ -827,18 +979,33 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( // default key, not fall back to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 - ? ((yield* resolveConfiguredSigningKeys(config, workdir)) ?? [DEFAULT_SIGNING_KEY])[0] + ? ((yield* collecting( + collector, + "auth.signing_keys_path", + resolveConfiguredSigningKeys(config, workdir), + undefined, + { + reason: "signing keys file is missing, unreadable or invalid", + help: "point signing_keys_path at a JSON array of JWKs, relative to the supabase/ directory", + }, + )) ?? [DEFAULT_SIGNING_KEY])[0] : undefined; // This block only accumulates the inputs passkey/webauthn/hook/mfa/email/smtp/third_party - // validation needs; the checks themselves run once, later, in the single - // `validateResolvedConfig` call below (sms/external run separately after it). + // validation needs; the checks themselves run once, later, in the `validateResolvedConfig` + // call below (sms/external run separately after it). let authInput: AuthInput | undefined; if (authEnabled) { const passkeyDoc = asRecord(authDocument?.["passkey"]); const webauthnDoc = asRecord(authDocument?.["webauthn"]); const passkeyEnabled = passkeyDoc !== undefined && - (yield* attempt(() => rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"))); + (yield* collecting( + collector, + "auth.passkey.enabled", + attempt(() => rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled")), + false, + { reason: "expected a boolean", help: "use true or false", code: "invalid_type" }, + )); const rpId = asString(webauthnDoc?.["rp_id"]); const rpOrigins = rawOrigins(webauthnDoc?.["rp_origins"]); const passkey: PasskeyInput | undefined = passkeyEnabled @@ -872,7 +1039,11 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( }, ]; - yield* readAuthEmailTemplateContent(resolveAuthEmail(config.auth.email, authDocument), workdir); + yield* readAuthEmailTemplateContent( + resolveAuthEmail(config.auth.email, authDocument), + workdir, + collector, + ); const resolvedSmtp = resolveAuthEmailSmtp(authDocument); const smtp: SmtpInput | undefined = @@ -894,15 +1065,31 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( hooks, mfa, smtp, - thirdParty: yield* attempt(() => resolveThirdPartyProviders(config.auth.third_party)), + thirdParty: yield* collecting( + collector, + "auth.third_party", + attempt(() => resolveThirdPartyProviders(config.auth.third_party)), + [], + { reason: "invalid third-party provider settings" }, + ), }; } const functionSlugs = Object.keys(config.functions); const denoVersion = config.edge_runtime.deno_version; const analyticsEnabled = config.analytics.enabled; - const analyticsBackend = yield* attempt(() => - narrowConfigEnum("analytics.backend", config.analytics.backend, ["postgres", "bigquery"]), + const analyticsBackend = yield* collecting( + collector, + "analytics.backend", + attempt(() => + narrowConfigEnum("analytics.backend", config.analytics.backend, ["postgres", "bigquery"]), + ), + "postgres" as const, + { + reason: "expected one of postgres, bigquery", + help: "use postgres or bigquery", + code: "invalid_enum", + }, ); const gcpProjectId = config.analytics.gcp_project_id; const gcpProjectNumber = config.analytics.gcp_project_number; @@ -959,21 +1146,43 @@ export const resolveLocalConfigValues = Effect.fnUntraced(function* ( experimental: experimentalInput, configFileName: configPath === undefined ? undefined : configFileName(configPath), }; - yield* attempt(() => validateResolvedConfig(input)); - if (authEnabled) { - yield* attempt(() => validateAuthSmsProviders(resolveAuthSms(config.auth.sms))); - yield* attempt(() => - validateAuthExternalProviders( - resolveAuthExternalProviders(authDocument, config.auth.external), - ), - ); + if (collector === undefined) { + yield* attempt(() => validateResolvedConfig(input)); + if (authEnabled) { + yield* attempt(() => validateAuthSmsProviders(resolveAuthSms(config.auth.sms))); + yield* attempt(() => + validateAuthExternalProviders( + resolveAuthExternalProviders(authDocument, config.auth.external), + ), + ); + } + } else { + collector.push(...collectConfigValidationIssues(input)); + if (authEnabled) { + collector.push(...collectAuthSmsProviderIssues(resolveAuthSms(config.auth.sms))); + collector.push( + ...collectAuthExternalProviderIssues( + resolveAuthExternalProviders(authDocument, config.auth.external, collector), + ), + ); + } } - const anonKey = yield* attempt(() => - resolveSignedKey(config.auth.anon_key, jwtSecret, signingKey, "anon"), + const anonKey = yield* collecting( + collector, + "auth.anon_key", + attempt(() => resolveSignedKey(config.auth.anon_key, jwtSecret, signingKey, "anon")), + "", + SIGNED_KEY_ISSUE, ); - const serviceRoleKey = yield* attempt(() => - resolveSignedKey(config.auth.service_role_key, jwtSecret, signingKey, "service_role"), + const serviceRoleKey = yield* collecting( + collector, + "auth.service_role_key", + attempt(() => + resolveSignedKey(config.auth.service_role_key, jwtSecret, signingKey, "service_role"), + ), + "", + SIGNED_KEY_ISSUE, ); return { diff --git a/apps/cli/src/command-internal/local-config-values.unit.test.ts b/apps/cli/src/command-internal/local-config-values.unit.test.ts index 79353d4832..11086cb60a 100644 --- a/apps/cli/src/command-internal/local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/local-config-values.unit.test.ts @@ -1033,6 +1033,7 @@ describe("narrowConfigEnum", () => { new CliConfigValueError({ path: "realtime.ip_version", tier: "config", + expected: "one of IPv4, IPv6", message: 'Invalid config for realtime.ip_version: "v9" must be one of "IPv4", "IPv6"', }), ); diff --git a/apps/cli/src/commands/config/config.command.ts b/apps/cli/src/commands/config/config.command.ts index f672b6d619..13ac28bf23 100644 --- a/apps/cli/src/commands/config/config.command.ts +++ b/apps/cli/src/commands/config/config.command.ts @@ -2,9 +2,15 @@ import { Command } from "effect/cli"; import { configDiffCommand } from "./diff/diff.command.ts"; import { configPullCommand } from "./pull/pull.command.ts"; import { configPushCommand } from "./push/push.command.ts"; +import { configValidateCommand } from "./validate/validate.command.ts"; export const configCommand = Command.make("config").pipe( Command.withDescription("Manage Supabase project configurations."), Command.withShortDescription("Manage project configurations"), - Command.withSubcommands([configDiffCommand, configPullCommand, configPushCommand]), + Command.withSubcommands([ + configDiffCommand, + configPullCommand, + configPushCommand, + configValidateCommand, + ]), ); diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md new file mode 100644 index 0000000000..5098ddd2c3 --- /dev/null +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -0,0 +1,73 @@ +# `supabase config validate` + +## Files Read + +| Path | Format | When | +| ----------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------- | +| `/supabase/config.toml`, `config.json` or `config.jsonc` | TOML/JSONC | always; the same text is reused for the code frames | +| `/supabase/.env*`, `/.env*` | dotenv | always, selected by `SUPABASE_ENV`, to resolve `env()` references and `SUPABASE_*` overrides | +| `/supabase/` | text | when an `auth.email.template.*` or notification `content_path` is set | +| `/supabase/`, `` | PEM | when `api.tls.enabled` is true | +| `/supabase/` | JSON | when `auth.signing_keys_path` is set | +| `~/.supabase/telemetry.json` | JSON | always (telemetry state) | + +`/supabase/.temp/project-ref` is never read: remotes are selected by name, not by the linked project. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (telemetry state) | +| `~/.supabase/cli-latest` | text | the global update notice, when its cache is stale; see Notes | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ---------------------- | +| — | — | — | — | — | + +## Environment Variables + +| Variable | Purpose | Required? | +| -------------------------------- | -------------------------------------------------------------------- | --------- | +| `SUPABASE_WORKDIR` / `--workdir` | project root used exactly as given | no | +| `SUPABASE_ENV` | selects which `.env.` files are read | no | +| `SUPABASE_` | overrides a config value; an invalid one is reported against its key | no | +| `SUPABASE_HOME` | location of `telemetry.json` | no | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------- | +| `0` | the config is valid; warnings never change the exit code | +| `1` | the config has problems; the report is printed and the command itself succeeds (same pattern as `db lint`) | +| `1` | `config.json` and `config.jsonc` both exist (`file_conflict`); `config.toml` next to a JSON file is a `shadowed_file` problem | +| `1` | a `[remotes.*]` block has an invalid or duplicate `project_id` | +| `1` | no project config found, `--workdir` is not a directory, or `-o/--output` was passed (generic error envelope in JSON mode) | + +Telemetry records a run that reports problems as a successful command, because the exit code is set after the handler succeeds. + +## Run order and stops + +Each stage reports every problem it finds; a run stops after a stage only when the later stages cannot be computed: `syntax`, `schema_version`, `file_conflict`, `env_file`. Remote blocks, unknown keys, removed keys, type and value errors and the semantic checks `supabase start` applies are all reported in one pass. JSON output sets `complete: false` and `stopped_at` when a run stopped early. + +## Output + +### `--output-format text` + +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****` unless the key is a known non-secret setting; `env(NAME)` references stay visible. Nothing is written to stderr by the command itself. + +### `--output-format json` + +`{"schema_version":1,"valid","complete","stopped_at"?,"file","format","remotes":[],"skipped_remotes"?,"errors":[],"warnings":[],"message"}`. Each entry has `code`, `severity`, `reason` and, when known, `key`, `path`, `remote`, `file`, `help`, `line`, `column`, `end_line`, `end_column`. Codes: `syntax`, `file_conflict`, `schema_version`, `removed_key`, `unknown_key`, `invalid_type`, `invalid_enum`, `invalid_value`, `missing_required`, `remote_project_id`, `env_file`, `removed_env`, `env_unresolved`, `deprecated_key`, `shadowed_file`, `unrecognized_schema`. It never contains source text or configured values. Failures before a report exists (no config, bad `--workdir`, `-o`) use the generic `{"_tag":"Error",...}` envelope. + +### `--output-format stream-json` + +Same structured result as `json`, delivered as an NDJSON `result` event. + +## Notes + +- No config or project file is created or changed and no Management API call is made. The CLI still writes `telemetry.json`, and the global update notice that follows every command can fetch the latest release tag and write `cli-latest` and print a notice on stderr; this command does not change that behaviour. +- Every `[remotes.]` block is validated merged over the base config, whatever the linked project is. +- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and errors it causes at or below that key are not reported. +- `-o/--output` is rejected in favour of `--output-format`. diff --git a/apps/cli/src/commands/config/validate/validate.command.ts b/apps/cli/src/commands/config/validate/validate.command.ts new file mode 100644 index 0000000000..5e73431078 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.command.ts @@ -0,0 +1,60 @@ +import type * as CliCommand from "effect/cli/Command"; +import { Layer } from "effect"; +import { Command } from "effect/cli"; + +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; +import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; +import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; +import { configValidate } from "./validate.handler.ts"; + +const config = {} as const; + +export type ConfigValidateFlags = CliCommand.Command.Config.Infer; + +const configValidateRuntimeLayer = Layer.mergeAll( + commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)), + telemetryStateLayer, + commandRuntimeLayer(["config", "validate"]), +); + +const configValidateHandler = (flags: ConfigValidateFlags) => + configValidate().pipe( + // The handler rejects `-o/--output` itself with a pointer to `--output-format`. + withCommandTelemetry({ flags, outputFormats: GLOBAL_OUTPUT_FORMATS }), + withJsonErrorHandling, + ); + +export const configValidateCommand = Command.make("validate", config).pipe( + Command.withDescription( + "Checks the project config file for syntax errors, unknown and removed keys, invalid values and failed validations, and reports every problem with its line. Never modifies files or contacts the Supabase API. Exits 1 when any error is found.", + ), + Command.withShortDescription("Validate the project config file"), + Command.withExamples([ + { + command: "supabase config validate", + description: "Validate supabase/config.toml, config.json or config.jsonc", + }, + { + command: "supabase config validate --workdir path/to/project", + description: "Validate the config of another project directory", + }, + { + command: "supabase config validate --output-format json", + description: "Print the report as JSON for scripts and agents", + }, + ]), + withPermissions({ operations: [] }), + Command.withHandler(configValidateHandler), + Command.provide(configValidateRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), +); diff --git a/apps/cli/src/commands/config/validate/validate.errors.ts b/apps/cli/src/commands/config/validate/validate.errors.ts new file mode 100644 index 0000000000..ce9422462a --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.errors.ts @@ -0,0 +1,33 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory. */ +export class ConfigValidateWorkdirError extends Data.TaggedError("ConfigValidateWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** The project config is missing or could not be read at all. */ +export class ConfigValidateLoadError extends Data.TaggedError("ConfigValidateLoadError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** The `-o/--output` flag was passed; this command only supports `--output-format`. */ +export class ConfigValidateOutputFlagUnsupportedError extends Data.TaggedError( + "ConfigValidateOutputFlagUnsupportedError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts new file mode 100644 index 0000000000..4b2e185b14 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -0,0 +1,377 @@ +import { escapeControlCharacters } from "../../../shared/output/escape-control-characters.ts"; +import { cliConfigLineKeys, type CliConfigLocation } from "@supabase/config/internal"; + +import { isPublicValuePath, type ConfigProblem } from "./validate.model.ts"; + +/** ANSI styling applied to the text report; the handler passes stream-gated colours. */ +export interface ReportStyle { + readonly bold: (text: string) => string; + readonly dim: (text: string) => string; + readonly green: (text: string) => string; + readonly red: (text: string) => string; + readonly yellow: (text: string) => string; + readonly code: (text: string) => string; +} + +const plain = (text: string) => text; + +export const PLAIN_REPORT_STYLE: ReportStyle = { + bold: plain, + dim: plain, + green: plain, + red: plain, + yellow: plain, + code: plain, +}; + +export type StopStage = "syntax" | "schema_version" | "file_conflict" | "env_file"; + +const STOP_LABELS: Readonly> = { + syntax: "the syntax check", + schema_version: "the schema version check", + file_conflict: "the config file check", + env_file: "loading the env files", +}; + +const PAYLOAD_VERSION = 1; + +export interface ValidateReport { + readonly file: string; + readonly format: "toml" | "json" | "jsonc"; + readonly source: string; + readonly remotes: ReadonlyArray; + readonly skippedRemotes: ReadonlyArray<{ readonly name: string; readonly reason: string }>; + readonly problems: ReadonlyArray; + readonly stoppedAt?: StopStage; +} + +const MASK = "****"; +const MAX_FRAME_WIDTH = 100; +const CARET_LEAD = 30; +const MAX_VALUE_LINES = 4; + +const errorsOf = (report: ValidateReport) => + sortedByLocation(report.problems.filter((problem) => problem.severity === "error")); +const warningsOf = (report: ValidateReport) => + sortedByLocation(report.problems.filter((problem) => problem.severity === "warning")); + +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; + +export function validateSummaryMessage(report: ValidateReport): string { + const errors = errorsOf(report).length; + return errors === 0 + ? `${report.file} is valid` + : `${report.file} has ${plural(errors, "problem")}`; +} + +const safe = escapeControlCharacters; + +const display = (text: string): string => + [...text] + .map((character) => { + if (character === "\t") return " "; + const code = character.codePointAt(0) ?? 0; + return code < 0x20 || (code >= 0x7f && code <= 0x9f) ? "·" : character; + }) + .join(""); + +interface Span { + readonly column: number; + readonly endColumn?: number; +} + +const KEY_VALUE = + /^(\s*(?:(?:#|\/\/)\s*)?(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+))*\s*[=:]\s*)(.*)$/; +const ENV_REFERENCE_VALUE = /^["']env\([A-Za-z_][A-Za-z0-9_]*\)["']\s*,?\s*$/; +const NO_VALUE = /^\s*(?:(?:#|\/\/).*|\[\[?[^\]=]*\]\]?\s*(?:#.*)?|[\][{},\s]*|("""|''')\s*,?)\s*$/; + +/** The parts of a source line that hold a value: everything right of a key, or a bare value line. */ +function valueSpans(text: string): ReadonlyArray { + const pair = KEY_VALUE.exec(text); + if (pair?.[1] !== undefined && pair[2] !== undefined) { + const value = pair[2]; + if (ENV_REFERENCE_VALUE.test(value) || /^[[{]?\s*$/.test(value)) return []; + return [{ column: pair[1].length + 1 }]; + } + if (NO_VALUE.test(text)) return []; + return [{ column: text.length - text.trimStart().length + 1 }]; +} + +interface MaskedLine { + readonly text: string; + readonly map: (index: number) => number; +} + +function maskLine(text: string, spans: ReadonlyArray): MaskedLine { + const ranges = spans + .map((span) => ({ + start: Math.min(span.column - 1, text.length), + end: span.endColumn === undefined ? text.length : Math.min(span.endColumn - 1, text.length), + })) + .sort((left, right) => left.start - right.start); + let out = ""; + let position = 0; + const applied: Array<{ start: number; end: number; outStart: number }> = []; + for (const range of ranges) { + if (range.end <= position) continue; + const start = Math.max(range.start, position); + out += text.slice(position, start); + applied.push({ start, end: range.end, outStart: out.length }); + out += MASK; + position = range.end; + } + out += text.slice(position); + return { + text: out, + map: (index) => { + let shift = 0; + for (const range of applied) { + if (index < range.start) break; + if (index < range.end) return range.outStart; + shift += MASK.length - (range.end - range.start); + } + return index + shift; + }, + }; +} + +const maskedLine = (text: string): MaskedLine => maskLine(text, valueSpans(text)); + +const TOKEN = /"(?:[^"\\]|\\.)*"|'[^']*'|[^\s,{}[\]:="']+/g; +const KEY_AHEAD = /^\s*[=:]/; + +/** The values in an inline fragment such as `{ a = 1, b = "x" }`: every token that is not a key or an `env()` reference. */ +function inlineValueSpans(text: string, from: number, to: number): ReadonlyArray { + const spans: Array = []; + for (const match of text.slice(from, to).matchAll(TOKEN)) { + const start = from + match.index; + const end = start + match[0].length; + if (KEY_AHEAD.test(text.slice(end, to)) || ENV_REFERENCE_VALUE.test(match[0])) continue; + spans.push({ column: start + 1, endColumn: end + 1 }); + } + return spans; +} + +/** + * A line whose located value the report may show: the keys stay readable, but every other value on + * it is masked, since an inline table or array can hold more than the one that failed. + */ +function shownLine(text: string, location: CliConfigLocation | undefined): MaskedLine { + if (location === undefined) return maskedLine(text); + const pair = KEY_VALUE.exec(text); + const headStart = pair?.[1]?.length ?? text.length - text.trimStart().length; + const valueEnd = location.endColumn === undefined ? text.length : location.endColumn - 1; + return maskLine(text, [ + ...inlineValueSpans(text, headStart, location.column - 1), + ...inlineValueSpans(text, valueEnd, text.length), + ]); +} + +function windowAround(text: string, caretOffset: number): { text: string; caretOffset: number } { + if (text.length <= MAX_FRAME_WIDTH) return { text, caretOffset }; + const from = Math.max(0, caretOffset - CARET_LEAD); + const slice = text.slice(from, from + MAX_FRAME_WIDTH); + const head = from > 0 ? "…" : ""; + const tail = from + MAX_FRAME_WIDTH < text.length ? "…" : ""; + return { text: `${head}${slice}${tail}`, caretOffset: caretOffset - from + head.length }; +} + +interface SourceView { + readonly lines: ReadonlyArray; + /** 1-based lines holding a single non-secret schema leaf, whose value context lines may show. */ + readonly publicLines: ReadonlySet; +} + +function sourceView(report: ValidateReport): SourceView { + const lines = report.source.replace(/^\uFEFF/, "").split(/\r?\n/); + const publicLines = new Set(); + for (const [line, path] of cliConfigLineKeys( + report.source, + report.format === "toml" ? "toml" : "json", + )) { + if (isPublicValuePath(path.join("."))) publicLines.add(line); + } + return { lines, publicLines }; +} + +function frameLines( + problem: ConfigProblem, + { lines, publicLines }: SourceView, + style: ReportStyle, +): ReadonlyArray { + const location = problem.location; + const first = location === undefined ? undefined : lines[location.line - 1]; + if (location === undefined || first === undefined) return []; + + const lastLine = Math.min( + location.endLine ?? location.line, + location.line + MAX_VALUE_LINES - 1, + lines.length, + ); + const width = String(lastLine).length; + const gutter = (label: string) => `${label.padStart(width)} │`; + const out: Array = []; + + if (location.line > 1) { + const previous = lines[location.line - 2] ?? ""; + const before = publicLines.has(location.line - 1) ? previous : maskedLine(previous).text; + const shown = display(before).trimEnd(); + const clipped = shown.length > MAX_FRAME_WIDTH ? `${shown.slice(0, MAX_FRAME_WIDTH)}…` : shown; + out.push( + ` ${style.dim(`${gutter(String(location.line - 1))}${clipped === "" ? "" : " "}${clipped}`)}`, + ); + } + + const render = (text: string, isFirst: boolean) => + problem.showValue ? shownLine(text, isFirst ? location : undefined) : maskedLine(text); + + const current = render(first, true); + const start = current.map(location.column - 1); + const singleLine = location.endLine === undefined || location.endLine === location.line; + const end = + singleLine && location.endColumn !== undefined + ? current.map(location.endColumn - 1) + : singleLine + ? start + 1 + : current.text.length; + const caretOffset = display(current.text.slice(0, start)).length; + const caretLength = Math.max(1, display(current.text.slice(start, end)).length); + const windowed = windowAround(display(current.text).trimEnd(), caretOffset); + out.push(` ${style.red(">")} ${style.dim(gutter(String(location.line)))} ${windowed.text}`); + out.push( + ` ${style.dim(gutter(""))} ${" ".repeat(windowed.caretOffset)}${style.red("^".repeat(caretLength))}`, + ); + for (let line = location.line + 1; line <= lastLine; line++) { + const text = display(render(lines[line - 1] ?? "", false).text).trimEnd(); + const clipped = text.length > MAX_FRAME_WIDTH ? `${text.slice(0, MAX_FRAME_WIDTH)}…` : text; + out.push(` ${style.red(">")} ${style.dim(gutter(String(line)))} ${clipped}`); + } + return out; +} + +const locationLabel = (report: ValidateReport, problem: ConfigProblem): string | undefined => + problem.location === undefined + ? undefined + : `${safe(report.file)}:${problem.location.line}:${problem.location.column}`; + +function sortedByLocation(problems: ReadonlyArray): ReadonlyArray { + return problems + .map((problem, index) => ({ problem, index })) + .sort((left, right) => { + const a = left.problem.location; + const b = right.problem.location; + if (a === undefined || b === undefined) { + return a === b ? left.index - right.index : a === undefined ? 1 : -1; + } + return a.line - b.line || a.column - b.column || left.index - right.index; + }) + .map(({ problem }) => problem); +} + +function renderProblem( + report: ValidateReport, + problem: ConfigProblem, + source: SourceView, + style: ReportStyle, +): ReadonlyArray { + const glyph = problem.severity === "error" ? style.red("✗") : style.yellow("⚠"); + const subject = safe(problem.path ?? problem.subject ?? report.file); + const remote = + problem.remote === undefined ? "" : ` ${style.dim(`[remote: ${safe(problem.remote)}]`)}`; + const out = [` ${glyph} ${style.bold(subject)} ${safe(problem.reason)}${remote}`]; + const label = locationLabel(report, problem); + if (label !== undefined) { + out.push(` ${style.dim(label)}`, ...frameLines(problem, source, style)); + } + if (problem.help !== undefined) { + out.push(` ${style.green("help:")} ${safe(problem.help)}`); + } + return out; +} + +const countsLabel = (errors: number, warnings: number): string => + [ + ...(errors > 0 ? [plural(errors, "error")] : []), + ...(warnings > 0 ? [plural(warnings, "warning")] : []), + ].join(", "); + +/** Renders the text report: every problem in file order as one shape, then a total. */ +export function validateReport( + report: ValidateReport, + style: ReportStyle = PLAIN_REPORT_STYLE, +): string { + const errors = errorsOf(report); + const warnings = warningsOf(report); + const source = sourceView(report); + const file = safe(report.file); + const out: Array = []; + + for (const problem of sortedByLocation(report.problems)) { + out.push(...renderProblem(report, problem, source, style), ""); + } + + if (errors.length === 0) { + const suffix = warnings.length === 0 ? "" : ` · ${plural(warnings.length, "warning")}`; + out.push(`${style.green("✓")} ${style.bold(`${file} is valid`)}${suffix}`); + } else { + out.push( + `${style.red("✗")} ${style.bold(`${countsLabel(errors.length, warnings.length)} in ${file}`)}`, + ); + } + if (report.remotes.length > 0) { + out.push(` ${style.dim(`Checked remotes: ${report.remotes.map(safe).join(", ")}`)}`); + } + if (report.skippedRemotes.length > 0) { + const skipped = report.skippedRemotes.map( + ({ name, reason }) => `${safe(name)} (${safe(reason)})`, + ); + out.push(` ${style.dim(`Skipped remotes: ${skipped.join(", ")}`)}`); + } + if (report.stoppedAt !== undefined) { + out.push( + "", + ` Stopped after ${STOP_LABELS[report.stoppedAt]}. Fix ${errors.length === 1 ? "this" : "these"} and run ${style.code("supabase config validate")} again to check the rest.`, + ); + } + out.push(""); + return out.join("\n"); +} + +function payloadProblem(problem: ConfigProblem) { + const key = + problem.path === undefined + ? undefined + : problem.remote === undefined + ? problem.path + : `remotes.${problem.remote}.${problem.path}`; + const { location } = problem; + return { + code: problem.code, + severity: problem.severity, + ...(key === undefined ? {} : { key, path: problem.path }), + ...(problem.remote === undefined ? {} : { remote: problem.remote }), + ...(problem.subject === undefined ? {} : { file: problem.subject }), + reason: problem.reason, + ...(problem.help === undefined ? {} : { help: problem.help }), + ...(location === undefined ? {} : { line: location.line, column: location.column }), + ...(location?.endLine === undefined ? {} : { end_line: location.endLine }), + ...(location?.endColumn === undefined ? {} : { end_column: location.endColumn }), + }; +} + +/** The JSON-mode payload; it carries key paths and positions, never source text or values. */ +export function validateJsonPayload(report: ValidateReport) { + const errors = errorsOf(report); + return { + schema_version: PAYLOAD_VERSION, + valid: errors.length === 0, + complete: report.stoppedAt === undefined, + ...(report.stoppedAt === undefined ? {} : { stopped_at: report.stoppedAt }), + file: report.file, + format: report.format, + remotes: report.remotes, + ...(report.skippedRemotes.length === 0 ? {} : { skipped_remotes: report.skippedRemotes }), + errors: errors.map(payloadProblem), + warnings: warningsOf(report).map(payloadProblem), + }; +} diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts new file mode 100644 index 0000000000..efd4b067e7 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; + +import { + PLAIN_REPORT_STYLE, + type ValidateReport, + validateJsonPayload, + validateReport, + validateSummaryMessage, +} from "./validate.format.ts"; +import type { ConfigProblem } from "./validate.model.ts"; + +const SOURCE = [ + 'project_id = "demo"', + "[api]", + "prot = 1", + 'secret = "hunter2"', + "port = 5", + 'cfg = { token = "hunter2", n = "bad" }', +].join("\n"); + +const problem = (fields: Partial): ConfigProblem => ({ + code: "invalid_type", + severity: "error", + reason: "bad", + showValue: false, + ...fields, +}); + +const report = (problems: ReadonlyArray, extra: Partial = {}) => + ({ + file: "supabase/config.toml", + format: "toml", + source: SOURCE, + remotes: [], + skippedRemotes: [], + problems, + ...extra, + }) satisfies ValidateReport; + +const render = (problems: ReadonlyArray, extra: Partial = {}) => + validateReport(report(problems, extra), PLAIN_REPORT_STYLE); + +describe("validateReport", () => { + it("draws a frame with the previous line, a caret under the span and the help line", () => { + const text = render([ + problem({ + code: "unknown_key", + path: "api.prot", + reason: "unknown key", + help: "did you mean `port`?", + location: { line: 3, column: 1, endColumn: 5, exact: true }, + }), + ]); + + expect(text).toBe( + [ + " ✗ api.prot unknown key", + " supabase/config.toml:3:1", + " 2 │ [api]", + " > 3 │ prot = ****", + " │ ^^^^", + " help: did you mean `port`?", + "", + "✗ 1 error in supabase/config.toml", + "", + ].join("\n"), + ); + }); + + it("masks every configured value by default, including the context line", () => { + const text = render([ + problem({ + path: "api.port", + location: { line: 5, column: 8, endColumn: 9, exact: true }, + }), + ]); + + expect(text).toContain(" > 5 │ port = ****"); + expect(text).not.toContain("hunter2"); + }); + + it("shows the located value only when the problem allows it, still masking siblings on the line", () => { + const text = render([ + problem({ + path: "api.cfg.n", + showValue: true, + location: { line: 6, column: 32, endColumn: 37, exact: true }, + }), + ]); + + expect(text).toContain('cfg = { token = ****, n = "bad" }'); + expect(text).not.toContain("hunter2"); + }); + + it("shows a known non-secret key's value on the context line", () => { + const text = render([ + problem({ + path: "api.cfg", + location: { line: 6, column: 1, endColumn: 4, exact: true }, + }), + ]); + + expect(text).toContain("5 │ port = 5"); + }); + + it("masks a secret on the context line", () => { + const text = render([ + problem({ + path: "api.port", + location: { line: 5, column: 1, endColumn: 5, exact: true }, + }), + ]); + + expect(text).toContain("4 │ secret = ****"); + expect(text).not.toContain("hunter2"); + }); + + it("masks an unknown key's value on the context line", () => { + const text = render([ + problem({ + path: "api.secret", + location: { line: 4, column: 1, endColumn: 7, exact: true }, + }), + ]); + + expect(text).toContain("3 │ prot = ****"); + }); + + it("labels remote problems and lists checked and skipped remotes", () => { + const text = render([problem({ path: "db.port", remote: "staging" })], { + remotes: ["staging"], + skippedRemotes: [{ name: "prod", reason: "no project id" }], + }); + + expect(text).toContain("[remote: staging]"); + expect(text).toContain("Checked remotes: staging"); + expect(text).toContain("Skipped remotes: prod (no project id)"); + }); + + it("reports a valid file with a warning count", () => { + const text = render([problem({ code: "deprecated_key", severity: "warning", path: "api.x" })]); + + expect(text).toContain("✓ supabase/config.toml is valid · 1 warning"); + }); + + it("says where a stopped run ended and what to do next", () => { + const text = render([problem({ code: "syntax" })], { stoppedAt: "syntax" }); + + expect(text).toContain( + "Stopped after the syntax check. Fix this and run supabase config validate again", + ); + }); + + it("neutralises control characters in a problem subject", () => { + const text = render([problem({ path: "a\u001b[31mb" })]); + + expect(text).not.toContain("\u001b"); + }); +}); + +describe("validateSummaryMessage", () => { + it("counts only errors", () => { + expect(validateSummaryMessage(report([]))).toBe("supabase/config.toml is valid"); + expect( + validateSummaryMessage(report([problem({}), problem({}), problem({ severity: "warning" })])), + ).toBe("supabase/config.toml has 2 problems"); + }); +}); + +describe("validateJsonPayload", () => { + it("carries keys, codes and positions but no source text", () => { + const payload = validateJsonPayload( + report( + [ + problem({ + path: "db.port", + remote: "staging", + help: "use a port", + location: { line: 5, column: 8, endLine: 5, endColumn: 9, exact: true }, + }), + problem({ code: "deprecated_key", severity: "warning", path: "api.x" }), + ], + { remotes: ["staging"] }, + ), + ); + + expect(payload).toEqual({ + schema_version: 1, + valid: false, + complete: true, + file: "supabase/config.toml", + format: "toml", + remotes: ["staging"], + errors: [ + { + code: "invalid_type", + severity: "error", + key: "remotes.staging.db.port", + path: "db.port", + remote: "staging", + reason: "bad", + help: "use a port", + line: 5, + column: 8, + end_line: 5, + end_column: 9, + }, + ], + warnings: [ + { code: "deprecated_key", severity: "warning", key: "api.x", path: "api.x", reason: "bad" }, + ], + }); + expect(JSON.stringify(payload)).not.toContain("hunter2"); + }); + + it("marks a stopped run incomplete", () => { + const payload = validateJsonPayload( + report([problem({ code: "syntax" })], { stoppedAt: "syntax" }), + ); + + expect(payload).toMatchObject({ valid: false, complete: false, stopped_at: "syntax" }); + }); +}); diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts new file mode 100644 index 0000000000..cee2468bf3 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -0,0 +1,607 @@ +import { + CliConfigFileConflictError, + CliConfigParseError, + CliConfigSchemaVersionError, +} from "@supabase/config/effect"; +import { + type CliConfigLocation, + locateCliConfigKey, + parseCliConfigDocumentFile, + type ParsedCliConfigDocument, +} from "@supabase/config/internal"; +import { Effect, FileSystem, Option, Result } from "effect"; + +import { + aqua, + bold, + type ColorStream, + dim, + green, + red, + yellow, +} from "../../../command-internal/colors.ts"; +import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { unsupportedOutputFlagMessage } from "../../../command-internal/output-formats.ts"; +import { + type ConfigValidationIssue, + missingIssue, +} from "../../../command-internal/config-validate.ts"; +import { collectLocalConfigIssues } from "../../../command-internal/local-config-values.ts"; +import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; +import { + missingProjectConfigMessageEffect, + relativeConfigPath, +} from "../../../command-internal/workdir-project.ts"; +import { CLI_CONFIG_OPTIONAL_ENV_PATHS } from "../../../config/cli-config-key-annotations.ts"; +import { getDocumentValue, isDocumentRecord } from "../../../config/cli-config-document.ts"; +import { flattenSchemaIssues } from "../../../config/cli-config-schema-issues.ts"; +import { + reportableUnknownCliConfigKeys, + suggestCliConfigKey, +} from "../../../config/cli-config-unknown-keys.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { + CliConfigValues, + type ResolvedCliConfig, +} from "../../../config/cli-config-values.service.ts"; +import { type CliConfigValueError } from "../../../config/cli-config.errors.ts"; +import { configParseCauseMessage } from "../../../shared/output/normalize-error.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; +import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { + ConfigValidateLoadError, + ConfigValidateOutputFlagUnsupportedError, + ConfigValidateWorkdirError, +} from "./validate.errors.ts"; +import { + type ReportStyle, + type StopStage, + validateJsonPayload, + validateReport, + type ValidateReport, + validateSummaryMessage, +} from "./validate.format.ts"; +import { + type ConfigProblem, + dedupeProblems, + envReferences, + type EnvReference, + isPublicValuePath, + nearestEnabledGate, + splitRemotePath, + withoutFollowOns, + withoutUnresolvedEnvErrors, +} from "./validate.model.ts"; + +type ReportFormat = ValidateReport["format"]; +type FileFormat = "toml" | "json"; + +const reportStyle = (stream: ColorStream): ReportStyle => ({ + bold: (text) => bold(text, stream), + dim: (text) => dim(text, stream), + green: (text) => green(text, stream), + red: (text) => red(text, stream), + yellow: (text) => yellow(text, stream), + code: (text) => aqua(text, stream), +}); + +const problem = ( + code: ConfigProblem["code"], + fields: Omit & { + readonly severity?: ConfigProblem["severity"]; + readonly showValue?: boolean; + }, +): ConfigProblem => ({ severity: "error", showValue: false, code, ...fields }); + +const withRemote = ( + problems: ReadonlyArray, + remote: string | undefined, +): ReadonlyArray => + remote === undefined + ? problems + : problems.map((item) => (item.remote === undefined ? { ...item, remote } : item)); + +const reportFormat = (file: string, format: FileFormat): ReportFormat => + format === "toml" ? "toml" : file.endsWith(".jsonc") ? "jsonc" : "json"; + +const stripBrackets = (key: string): string => key.replace(/^\[|\]$/g, ""); + +function removedKeyProblems( + parsed: ParsedCliConfigDocument, + format: ReportFormat, +): ReadonlyArray { + return parsed.removedKeys.map(({ key, replacement }) => { + const split = splitRemotePath(stripBrackets(key).split(".")); + const local = split.path.join("."); + const replacementLocal = splitRemotePath(stripBrackets(replacement).split(".")).path.join("."); + const isTable = key.startsWith("["); + return problem("removed_key", { + path: local, + reason: "removed", + help: + isTable && format === "toml" + ? `rename [${local}] to [${replacementLocal}]` + : `use ${replacementLocal} instead`, + ...(split.remote === undefined ? {} : { remote: split.remote }), + }); + }); +} + +const numberField = (cause: unknown, field: string): number | undefined => { + const value = typeof cause === "object" && cause !== null ? Reflect.get(cause, field) : undefined; + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +}; + +function syntaxProblem(error: CliConfigParseError): ConfigProblem { + const line = numberField(error.cause, "line"); + const column = numberField(error.cause, "column"); + return problem("syntax", { + reason: configParseCauseMessage(error.cause).replace(/ \(line \d+, column \d+\)$/, ""), + ...(line === undefined ? {} : { location: { line, column: column ?? 1, exact: true } }), + }); +} + +const SCHEMA_DIRECTIVE = /^[ \t]*#:schema[ \t]+(\S+)/m; + +function locateSchemaDeclaration( + source: string, + format: ReportFormat, +): CliConfigLocation | undefined { + if (format !== "toml") return locateCliConfigKey(source, "json", ["$schema"], "value"); + const match = SCHEMA_DIRECTIVE.exec(source); + if (match === null || match[1] === undefined) return undefined; + const before = source.slice(0, match.index + match[0].length - match[1].length); + const lines = before.split("\n"); + const column = (lines.at(-1)?.length ?? 0) + 1; + return { + line: lines.length, + column, + endLine: lines.length, + endColumn: column + match[1].length, + exact: true, + }; +} + +const KEY_TARGET_CODES: ReadonlySet = new Set([ + "unknown_key", + "removed_key", + "deprecated_key", +]); + +function locateProblem( + item: ConfigProblem, + source: string, + format: FileFormat, +): CliConfigLocation | undefined { + if (item.location !== undefined || item.path === undefined || item.external !== undefined) { + return item.location; + } + const segments = item.path.split("."); + const target = KEY_TARGET_CODES.has(item.code) ? "key" : "value"; + const attempts = [ + ...(item.remote === undefined ? [] : [["remotes", item.remote, ...segments]]), + segments, + ].map((path) => locateCliConfigKey(source, format, path, target)); + return ( + attempts.find((found) => found?.exact === true) ?? attempts.find((found) => found !== undefined) + ); +} + +function deprecatedKeyProblems(parsed: ParsedCliConfigDocument): ReadonlyArray { + return parsed.deprecatedPaths.map((dotted) => { + const split = splitRemotePath(dotted.split(".")); + const local = split.path.join("."); + return problem("deprecated_key", { + severity: "warning", + path: local, + reason: "deprecated, will be removed in v4", + help: `use ${local.replace(/enable_signup$/, "enabled")} instead`, + ...(split.remote === undefined ? {} : { remote: split.remote }), + }); + }); +} + +function unknownKeyProblems(parsed: ParsedCliConfigDocument): ReadonlyArray { + return reportableUnknownCliConfigKeys(parsed.rawDocument).map(({ path, knownKeys }) => { + const isTable = isDocumentRecord(getDocumentValue(parsed.rawDocument, path)); + const suggestion = suggestCliConfigKey(path.at(-1) ?? "", knownKeys); + const split = splitRemotePath(path); + return problem("unknown_key", { + path: split.path.join("."), + reason: isTable ? "unknown table" : "unknown key", + ...(suggestion === undefined ? {} : { help: `did you mean \`${suggestion}\`?` }), + ...(split.remote === undefined ? {} : { remote: split.remote }), + }); + }); +} + +const schemaProblems = ( + failures: ReadonlyArray, +): ReadonlyArray => + failures.flatMap((failure) => { + const flat = flattenSchemaIssues(failure.cause); + if (flat.length === 0) { + return [problem("invalid_value", { reason: "does not match the config schema" })]; + } + return flat.map((issue) => { + const path = issue.path.join("."); + const detail = + issue.code === "missing_required" && path !== "" + ? missingIssue(path, "") + : { reason: issue.reason, help: issue.help }; + return problem(issue.code, { + ...(path === "" ? {} : { path }), + reason: detail.reason, + ...(detail.help === undefined ? {} : { help: detail.help }), + showValue: issue.code !== "missing_required" && path !== "" && isPublicValuePath(path), + }); + }); + }); + +function invalidValueProblem(error: CliConfigValueError, file: string): ConfigProblem { + const fromEnv = error.tier === "shell" || error.tier === "projectEnv"; + const reason = + error.expected !== undefined + ? `expected ${error.expected}` + : error.issues !== undefined && error.issues.length > 0 + ? error.issues.join("; ") + : "invalid value"; + return problem("invalid_value", { + path: error.path, + reason, + ...(error.expected?.startsWith("one of") ? { help: `use ${error.expected}` } : {}), + ...(fromEnv && error.source !== undefined + ? { + external: true as const, + help: `${error.source} sets this value, not ${file}; fix or unset it there`, + } + : { showValue: isPublicValuePath(error.path) }), + }); +} + +function semanticProblem(issue: ConfigValidationIssue): ConfigProblem { + const { code } = issue; + return problem(code, { + ...(issue.path === "" ? {} : { path: issue.path }), + reason: issue.reason, + ...(issue.help === undefined ? {} : { help: issue.help }), + showValue: code !== "missing_required" && issue.path !== "" && isPublicValuePath(issue.path), + }); +} + +function remoteFailureProblem(failure: { remote: string; message: string }): ConfigProblem { + const duplicate = /^duplicate project_id for \[remotes\.[^\]]+\] and \[remotes\.([^\]]+)\]$/.exec( + failure.message, + ); + return problem("remote_project_id", { + path: "project_id", + remote: failure.remote, + reason: + duplicate?.[1] === undefined + ? "not a project ref" + : `same project_id as remotes.${duplicate[1]}`, + help: + duplicate?.[1] === undefined + ? "use the 20-character ref from your project's dashboard URL" + : "give each remote its own project_id", + showValue: true, + }); +} + +export const configValidate = Effect.fn("config.validate")(function* () { + const output = yield* Output; + const telemetryState = yield* TelemetryState; + const cliSettings = yield* CommandSettings; + const outputFlag = yield* OutputFlag; + const processControl = yield* ProcessControl; + const fs = yield* FileSystem.FileSystem; + const configValues = yield* CliConfigValues; + const { workdir } = cliSettings; + + const emit = Effect.fnUntraced(function* (report: ValidateReport) { + const errors = report.problems.filter((item) => item.severity === "error"); + yield* Effect.annotateCurrentSpan({ + "config.error_count": errors.length, + "config.warning_count": report.problems.length - errors.length, + "config.remote_count": report.remotes.length, + "config.format": report.format, + "config.complete": report.stoppedAt === undefined, + }); + if (output.format !== "text") { + yield* output.success(validateSummaryMessage(report), validateJsonPayload(report)); + } else { + yield* output.raw(validateReport(report, reportStyle(process.stdout))); + } + if (errors.length > 0) yield* processControl.setExitCode(1); + }); + + const readSource = (file: string) => fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")); + + const stopReport = Effect.fnUntraced(function* ( + file: string, + format: FileFormat, + stoppedAt: StopStage, + problems: ReadonlyArray, + options: { readonly readSource: boolean } = { readSource: true }, + ) { + const source = options.readSource ? yield* readSource(file) : ""; + const display = relativeConfigPath(workdir, file); + return { + file: display, + format: reportFormat(display, format), + source, + remotes: [], + skippedRemotes: [], + stoppedAt, + problems: problems.map((item) => ({ + ...item, + location: locateProblem(item, source, format), + })), + } satisfies ValidateReport; + }); + + yield* Effect.gen(function* () { + if (Option.isSome(outputFlag)) { + return yield* new ConfigValidateOutputFlagUnsupportedError({ + message: unsupportedOutputFlagMessage("config validate"), + }); + } + yield* validateWorkdirIsDirectory(workdir, fs).pipe( + Effect.mapError((error) => new ConfigValidateWorkdirError({ message: error.message })), + ); + + const parsedResult = yield* parseCliConfigDocumentFile(workdir, { + search: false, + tolerateRemovedKeys: true, + }).pipe(Effect.result, Effect.withSpan("config.validate.read")); + if (Result.isFailure(parsedResult)) { + const error = parsedResult.failure; + if (error instanceof CliConfigFileConflictError) { + const [first] = error.paths; + const shown = error.paths.map((path) => relativeConfigPath(workdir, path)); + return yield* emit( + yield* stopReport( + first ?? "supabase/config.toml", + "json", + "file_conflict", + [ + problem("file_conflict", { + reason: `${shown.join(" and ")} both exist`, + help: "delete the file you don't use", + }), + ], + { readSource: false }, + ), + ); + } + if (error instanceof CliConfigSchemaVersionError) { + const source = yield* readSource(error.path); + return yield* emit( + yield* stopReport(error.path, error.format, "schema_version", [ + problem("schema_version", { + reason: error.message, + location: locateSchemaDeclaration(source, reportFormat(error.path, error.format)), + showValue: true, + }), + ]), + ); + } + if (error instanceof CliConfigParseError) { + return yield* emit( + yield* stopReport(error.path, error.format, "syntax", [syntaxProblem(error)]), + ); + } + return yield* new ConfigValidateLoadError({ message: error.message }); + } + + const parsed = parsedResult.success; + if (parsed === null) { + return yield* new ConfigValidateLoadError({ + message: yield* missingProjectConfigMessageEffect(cliSettings), + }); + } + + const file = relativeConfigPath(workdir, parsed.path); + const format = reportFormat(file, parsed.format); + const rawRemotes = parsed.rawDocument?.["remotes"]; + const remoteNames = isDocumentRecord(rawRemotes) ? Object.keys(rawRemotes) : []; + + const notices: Array = [ + ...removedKeyProblems(parsed, format), + ...deprecatedKeyProblems(parsed), + ...parsed.ignoredPaths.map((ignored) => + problem("shadowed_file", { + subject: relativeConfigPath(workdir, ignored), + reason: `is not validated or used: ${file} takes precedence`, + help: "delete the file you don't use", + }), + ), + ...unknownKeyProblems(parsed), + ]; + const declaration = parsed.schemaDeclaration; + if (declaration !== undefined && declaration.version === undefined) { + notices.push( + problem("unrecognized_schema", { + severity: "warning", + subject: file, + reason: `${declaration.ref} is not a Supabase config schema, so the file is read as schema v1`, + help: "set it to https://supabase.com/schemas/config/v1.json", + location: locateSchemaDeclaration(parsed.rawText, format), + showValue: true, + }), + ); + } + + interface PassResult { + readonly problems: ReadonlyArray; + readonly resolved: ResolvedCliConfig | undefined; + readonly stop?: StopStage; + } + + const pass = Effect.fnUntraced(function* (remote: string | undefined) { + const result = yield* configValues + .load({ + workdir, + projectRef: Option.none(), + validation: true, + ...(remote === undefined ? {} : { remoteName: remote }), + }) + .pipe(Effect.result); + if (Result.isFailure(result)) { + const error = result.failure; + const failed = (problems: ReadonlyArray, stop?: StopStage): PassResult => ({ + problems: withRemote(problems, remote), + resolved: undefined, + ...(stop === undefined ? {} : { stop }), + }); + switch (error._tag) { + case "CliConfigParseError": + return failed(schemaProblems([error])); + case "CliConfigValueError": + return failed([invalidValueProblem(error, file)]); + case "CliConfigRemovedEnvError": + return failed([problem("removed_env", { reason: error.message })]); + case "CliConfigLoadError": + return failed( + [ + problem("env_file", { + subject: "supabase/.env*", + reason: "could not be read", + help: "check that the env files under supabase/ are readable files", + }), + ], + "env_file", + ); + default: + return yield* new ConfigValidateLoadError({ message: error.message }); + } + } + const resolved = result.success; + const semantic = yield* collectLocalConfigIssues( + resolved.materialized.config, + workdir, + resolved.loaded.document, + resolved.loaded.path, + ); + const removedPaths = [ + ...resolved.pruned, + ...resolved.invalid.map((error) => error.path.split(".")), + ]; + const passed: PassResult = { + problems: withRemote( + [ + ...schemaProblems(resolved.schemaFailures), + ...resolved.invalid.map((error) => invalidValueProblem(error, file)), + ...withoutFollowOns(semantic.map(semanticProblem), removedPaths), + ], + remote, + ), + resolved, + }; + return passed; + }); + + const base = yield* pass(undefined).pipe(Effect.withSpan("config.validate.base")); + if (base.stop !== undefined) { + const report = { + file, + format, + source: parsed.rawText, + remotes: [], + skippedRemotes: [], + stoppedAt: base.stop, + problems: [...notices, ...base.problems].map((item) => ({ + ...item, + location: locateProblem(item, parsed.rawText, parsed.format), + })), + } satisfies ValidateReport; + return yield* emit(report); + } + + const failedRemotes = new Map( + (base.resolved?.remoteFailures ?? []).map((failure) => [failure.remote, failure]), + ); + const skippedRemotes = remoteNames + .filter((name) => failedRemotes.has(name)) + .map((name) => ({ name, reason: "invalid project_id" })); + const checkedRemotes = remoteNames.filter((name) => !failedRemotes.has(name)); + const remotePasses = yield* Effect.forEach(checkedRemotes, (name) => pass(name), { + concurrency: 1, + }).pipe(Effect.withSpan("config.validate.remotes")); + const passes = new Map([ + [undefined, base], + ...checkedRemotes.map((name, index): [string, PassResult] => [name, remotePasses[index]!]), + ]); + + const references = envReferences(parsed.rawDocument); + const unresolved = yield* Effect.gen(function* () { + if (references.length === 0) return [] as ReadonlyArray; + const resolved: ResolvedCliConfig | undefined = + base.resolved ?? + (yield* configValues + .load({ + workdir, + projectRef: Option.none(), + ignoreConfigFile: true, + validation: true, + }) + .pipe(Effect.option, Effect.map(Option.getOrUndefined))); + if (resolved === undefined) return []; + const values = yield* resolved + .envValues(references.map((reference) => reference.name)) + .pipe(Effect.orElseSucceed((): Record => ({}))); + return references.filter((reference) => values[reference.name] === undefined); + }).pipe(Effect.withSpan("config.validate.env")); + + const remoteWrites = (remote: string, path: string): boolean => + isDocumentRecord(rawRemotes) && + getDocumentValue(rawRemotes[remote], path.split(".")) !== undefined; + const { kept, blamed } = withoutUnresolvedEnvErrors( + dedupeProblems( + [ + ...notices, + ...base.problems, + ...(base.resolved?.remoteFailures ?? []).map(remoteFailureProblem), + ...remotePasses.flatMap((item) => item.problems), + ], + remoteWrites, + ), + unresolved, + ); + + const isRelevantReference = (reference: EnvReference): boolean => { + if (CLI_CONFIG_OPTIONAL_ENV_PATHS[reference.path.join(".")] !== undefined) return false; + const owner = passes.get(reference.remote)?.resolved; + const gate = + nearestEnabledGate(owner?.materialized.config, reference.path) ?? + nearestEnabledGate(owner?.loaded.document, reference.path); + return gate !== false; + }; + const envWarnings = unresolved + .filter((reference) => blamed.includes(reference) || isRelevantReference(reference)) + .map((reference) => + problem("env_unresolved", { + severity: "warning", + path: reference.path.join("."), + reason: `${reference.name} is not set, so this key gets the literal text "env(${reference.name})"`, + help: `set ${reference.name} in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)`, + showValue: true, + ...(reference.remote === undefined ? {} : { remote: reference.remote }), + }), + ); + const located = [...kept, ...envWarnings].map((item) => ({ + ...item, + location: locateProblem(item, parsed.rawText, parsed.format), + })); + + return yield* emit({ + file, + format, + source: parsed.rawText, + remotes: checkedRemotes, + skippedRemotes, + problems: located, + }); + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts new file mode 100644 index 0000000000..7c08a73261 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -0,0 +1,766 @@ +import { inspect } from "node:util"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path, Predicate } from "effect"; + +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, + useTempWorkdir, +} from "../../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; +import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { renderCliConfigTemplate } from "../../../shared/init/project-init.templates.ts"; +import type { OutputFormat } from "../../../shared/output/types.ts"; +import { configValidate } from "./validate.handler.ts"; + +const tempRoot = useTempWorkdir("supabase-config-validate-"); + +interface RunOptions { + readonly format?: OutputFormat; + readonly outputFlag?: "json"; + readonly env?: Readonly>; +} + +const writeFile = Effect.fnUntraced(function* (relative: string, text: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(tempRoot.current, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, text); +}); + +const validate = Effect.fnUntraced(function* ( + files: Readonly>, + options: RunOptions = {}, +) { + for (const [relative, text] of Object.entries(files)) yield* writeFile(relative, text); + const out = mockOutput({ format: options.format ?? "text" }); + const telemetry = mockTelemetryStateTracked(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + processControl.layer, + mockCommandSettings({ workdir: tempRoot.current, explicitWorkdir: true }), + Layer.succeed( + OutputFlag, + options.outputFlag === undefined ? Option.none() : Option.some(options.outputFlag), + ), + configValuesLayer({ env: options.env }), + BunServices.layer, + ); + const outcome = yield* configValidate().pipe(Effect.provide(layer), Effect.result); + return { + stdout: out.stdoutText, + stderr: out.stderrText, + failure: outcome._tag === "Failure" ? outcome.failure : undefined, + failureTag: + outcome._tag === "Failure" && Predicate.hasProperty(outcome.failure, "_tag") + ? outcome.failure._tag + : undefined, + exitCode: processControl.exitCode, + successes: out.messages.filter((message) => message.type === "success"), + flushed: telemetry.flushed, + }; +}); + +const tomlFile = (text: string) => ({ "supabase/config.toml": text }); + +const SPEC_TOML = `project_id = "demo" + +[api] +enabled = true +prot = 54321 +max_rows = "lots" + +[db] +major_version = 16 + +[auth.external.github] +enabled = true + +[auth.email.smtp] +enabled = true +host = "smtp.test" +port = 587 +user = "mailer" +pass = "env(SMTP_PASS)" +admin_email = "a@test.dev" + +[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.api] +max_rows = "many" +`; + +const SPEC_JSONC = `{ + // three problems on three lines + "project_id": "demo", + "api": { + "prot": 54321, + "max_rows": "lots", + }, + "db": { "major_version": 16 } +} +`; + +const reportedTexts = (result: { + readonly stdout: string; + readonly successes: ReadonlyArray; +}) => `${result.stdout}\n${inspect(result.successes, { depth: 10 })}`; + +const payloadOf = (result: { readonly successes: ReadonlyArray<{ readonly data?: unknown }> }) => + result.successes[0]?.data; + +describe("config validate integration", () => { + it.effect("accepts the init template with no warnings", () => + Effect.gen(function* () { + const text = yield* validate(tomlFile(renderCliConfigTemplate("demo", false))); + expect(text.failure).toBeUndefined(); + expect(text.stdout).toBe("✓ supabase/config.toml is valid\n"); + expect(text.stderr).toBe(""); + expect(text.exitCode).toBeUndefined(); + expect(text.flushed).toBe(true); + + const json = yield* validate(tomlFile(renderCliConfigTemplate("demo", false)), { + format: "json", + }); + expect(payloadOf(json)).toEqual({ + schema_version: 1, + valid: true, + complete: true, + file: "supabase/config.toml", + format: "toml", + remotes: [], + errors: [], + warnings: [], + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("accepts JSON and JSONC configs", () => + Effect.gen(function* () { + const json = yield* validate({ "supabase/config.json": '{ "project_id": "demo" }' }); + expect(json.stdout).toBe("✓ supabase/config.json is valid\n"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("accepts a JSONC config with comments and trailing commas", () => + Effect.gen(function* () { + const result = yield* validate({ + "supabase/config.jsonc": '{\n // name\n "project_id": "demo",\n}\n', + }); + expect(result.stdout).toBe("✓ supabase/config.jsonc is valid\n"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports every problem of the spec config in one run and exits 1 quietly", () => + Effect.gen(function* () { + const result = yield* validate(tomlFile(SPEC_TOML)); + + expect(result.failure).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(1); + expect(result.stdout).toMatchInlineSnapshot(` + " ✗ api.prot unknown key + supabase/config.toml:5:1 + 4 │ enabled = true + > 5 │ prot = **** + │ ^^^^ + help: did you mean \`port\`? + + ✗ api.max_rows expected a non-negative integer + supabase/config.toml:6:12 + 5 │ prot = **** + > 6 │ max_rows = "lots" + │ ^^^^^^ + + ✗ db.major_version unsupported Postgres version + supabase/config.toml:9:17 + 8 │ [db] + > 9 │ major_version = 16 + │ ^^ + help: use one of 13, 14, 15, 17 + + ✗ auth.external.github.client_id required when auth.external.github.enabled = true + supabase/config.toml:11:2 + 10 │ + > 11 │ [auth.external.github] + │ ^^^^^^^^^^^^^^^^^^^^ + help: set client_id, or set enabled = false + + ✗ auth.external.github.secret required when auth.external.github.enabled = true + supabase/config.toml:11:2 + 10 │ + > 11 │ [auth.external.github] + │ ^^^^^^^^^^^^^^^^^^^^ + help: set secret, or set enabled = false + + ⚠ auth.email.smtp.pass SMTP_PASS is not set, so this key gets the literal text "env(SMTP_PASS)" + supabase/config.toml:19:8 + 18 │ user = "mailer" + > 19 │ pass = "env(SMTP_PASS)" + │ ^^^^^^^^^^^^^^^^ + help: set SMTP_PASS in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.) + + ✗ api.max_rows expected a non-negative integer [remote: staging] + supabase/config.toml:26:12 + 25 │ [remotes.staging.api] + > 26 │ max_rows = "many" + │ ^^^^^^ + + ✗ 6 errors, 1 warning in supabase/config.toml + Checked remotes: staging + " + `); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports the spec config as a JSON payload", () => + Effect.gen(function* () { + const result = yield* validate(tomlFile(SPEC_TOML), { format: "json" }); + + expect(result.exitCode).toBe(1); + expect(payloadOf(result)).toMatchInlineSnapshot(` + { + "complete": true, + "errors": [ + { + "code": "unknown_key", + "column": 1, + "end_column": 5, + "end_line": 5, + "help": "did you mean \`port\`?", + "key": "api.prot", + "line": 5, + "path": "api.prot", + "reason": "unknown key", + "severity": "error", + }, + { + "code": "invalid_value", + "column": 12, + "end_column": 18, + "end_line": 6, + "key": "api.max_rows", + "line": 6, + "path": "api.max_rows", + "reason": "expected a non-negative integer", + "severity": "error", + }, + { + "code": "invalid_value", + "column": 17, + "end_column": 19, + "end_line": 9, + "help": "use one of 13, 14, 15, 17", + "key": "db.major_version", + "line": 9, + "path": "db.major_version", + "reason": "unsupported Postgres version", + "severity": "error", + }, + { + "code": "missing_required", + "column": 2, + "end_column": 22, + "end_line": 11, + "help": "set client_id, or set enabled = false", + "key": "auth.external.github.client_id", + "line": 11, + "path": "auth.external.github.client_id", + "reason": "required when auth.external.github.enabled = true", + "severity": "error", + }, + { + "code": "missing_required", + "column": 2, + "end_column": 22, + "end_line": 11, + "help": "set secret, or set enabled = false", + "key": "auth.external.github.secret", + "line": 11, + "path": "auth.external.github.secret", + "reason": "required when auth.external.github.enabled = true", + "severity": "error", + }, + { + "code": "invalid_value", + "column": 12, + "end_column": 18, + "end_line": 26, + "key": "remotes.staging.api.max_rows", + "line": 26, + "path": "api.max_rows", + "reason": "expected a non-negative integer", + "remote": "staging", + "severity": "error", + }, + ], + "file": "supabase/config.toml", + "format": "toml", + "remotes": [ + "staging", + ], + "schema_version": 1, + "valid": false, + "warnings": [ + { + "code": "env_unresolved", + "column": 8, + "end_column": 24, + "end_line": 19, + "help": "set SMTP_PASS in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)", + "key": "auth.email.smtp.pass", + "line": 19, + "path": "auth.email.smtp.pass", + "reason": "SMTP_PASS is not set, so this key gets the literal text "env(SMTP_PASS)"", + "severity": "warning", + }, + ], + } + `); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports problems in a JSONC config", () => + Effect.gen(function* () { + const result = yield* validate({ "supabase/config.jsonc": SPEC_JSONC }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toMatchInlineSnapshot(` + " ✗ api.prot unknown key + supabase/config.jsonc:5:5 + 4 │ "api": { + > 5 │ "prot": **** + │ ^^^^^^ + help: did you mean \`port\`? + + ✗ api.max_rows expected a non-negative integer + supabase/config.jsonc:6:17 + 5 │ "prot": **** + > 6 │ "max_rows": "lots", + │ ^^^^^^ + + ✗ db.major_version unsupported Postgres version + supabase/config.jsonc:8:28 + 7 │ }, + > 8 │ "db": { "major_version": 16 } + │ ^^ + help: use one of 13, 14, 15, 17 + + ✗ 3 errors in supabase/config.jsonc + " + `); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps checking values and validations after a schema error", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[api]\nmax_rows = "lots"\n[db]\nmajor_version = 16\nport = 54322\n[auth.captcha]\nenabled = true\nprovider = "turnstile"\n`, + ), + { env: { SUPABASE_STUDIO_PORT: "nope" } }, + ); + + const data = payloadOf( + yield* validate(tomlFile('project_id = "demo"\n'), { format: "json" }), + ); + expect(data).toMatchObject({ valid: true }); + expect(result.stdout).toContain("api.max_rows"); + expect(result.stdout).toContain("db.major_version unsupported Postgres version"); + expect(result.stdout).toContain("studio.port"); + expect(result.stdout).toContain( + "auth.captcha.secret required when auth.captcha.enabled = true", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports a removed key together with a typo and the rest", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[inbucket]\nport = 54324\n[api]\nprot = 1\n[db]\nmajor_version = 16\n`, + ), + { format: "json" }, + ); + + const data = payloadOf(result); + expect(data).toMatchObject({ valid: false, complete: true }); + expect(Reflect.get(data ?? {}, "errors")).toMatchObject([ + { code: "removed_key", key: "inbucket", help: "rename [inbucket] to [local_smtp]" }, + { code: "unknown_key", key: "api.prot" }, + { code: "invalid_value", key: "db.major_version" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps validating the base when a remote has a structural error", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[db]\nmajor_version = 16\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.api]\nmax_rows = "many"\n`, + ), + ); + + expect(result.stdout).toContain("db.major_version unsupported Postgres version"); + expect(result.stdout).toContain("api.max_rows"); + expect(result.stdout).toContain("[remote: prod]"); + expect(result.stdout).not.toContain("[remote: prod]\n supabase/config.toml:3"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports every bad remote and still validates the healthy ones", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[remotes.a]\nproject_id = "bad"\n[remotes.b]\nproject_id = "worse"\n[remotes.c]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.c.api]\nmax_rows = "many"\n`, + ), + { format: "json" }, + ); + + const data = payloadOf(result); + expect(data).toMatchObject({ + remotes: ["c"], + skipped_remotes: [ + { name: "a", reason: "invalid project_id" }, + { name: "b", reason: "invalid project_id" }, + ], + }); + expect(Reflect.get(data ?? {}, "errors")).toMatchObject([ + { code: "remote_project_id", key: "remotes.a.project_id", remote: "a" }, + { code: "remote_project_id", key: "remotes.b.project_id", remote: "b" }, + { code: "invalid_value", key: "remotes.c.api.max_rows", remote: "c" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports a duplicate remote project_id against the later remote", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[remotes.a]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.b]\nproject_id = "abcdefghijklmnopqrst"\n`, + ), + ); + + expect(result.stdout).toContain("project_id same project_id as remotes.a [remote: b]"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("stops and says so when the env files cannot be read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tempRoot.current, "supabase", ".env"), { recursive: true }); + const result = yield* validate(tomlFile('project_id = "demo"\n[db]\nmajor_version = 16\n'), { + format: "json", + }); + + expect(payloadOf(result)).toMatchObject({ + valid: false, + complete: false, + stopped_at: "env_file", + }); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([{ code: "env_file" }]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("stops after a syntax error and reports it as incomplete", () => + Effect.gen(function* () { + const text = yield* validate(tomlFile('project_id = "demo"\n[db\nport = 1\n')); + expect(text.stdout).toContain("Stopped after the syntax check."); + expect(text.stdout).toContain("run supabase config validate again to check the rest"); + + const json = yield* validate(tomlFile('project_id = "demo"\n[db\nport = 1\n'), { + format: "json", + }); + expect(payloadOf(json)).toMatchObject({ + valid: false, + complete: false, + stopped_at: "syntax", + }); + expect(Reflect.get(payloadOf(json) ?? {}, "errors")).toMatchObject([ + { code: "syntax", line: 2 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("stops when config.json and config.jsonc both exist", () => + Effect.gen(function* () { + const result = yield* validate( + { + "supabase/config.json": '{ "project_id": "a" }', + "supabase/config.jsonc": '{ "project_id": "b" }', + }, + { format: "json" }, + ); + + expect(payloadOf(result)).toMatchObject({ + valid: false, + complete: false, + stopped_at: "file_conflict", + }); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { + code: "file_conflict", + reason: "supabase/config.json and supabase/config.jsonc both exist", + help: "delete the file you don't use", + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("points at the schema declaration when the config schema is too new", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('#:schema https://supabase.com/schemas/config/v99.json\nproject_id = "demo"\n'), + ); + + expect(result.stdout).toContain("supabase/config.toml:1:10"); + expect(result.stdout).toContain("Stopped after the schema version check."); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("treats a config.toml beside config.json as an error", () => + Effect.gen(function* () { + const result = yield* validate( + { + "supabase/config.json": '{ "project_id": "demo" }', + "supabase/config.toml": 'project_id = "other"\n', + }, + { format: "json" }, + ); + + expect(result.exitCode).toBe(1); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { + code: "shadowed_file", + file: "supabase/config.toml", + reason: "is not validated or used: supabase/config.json takes precedence", + help: "delete the file you don't use", + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("warns about an unset env() reference in an enabled section", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.email.smtp]\nenabled = true\nhost = "h"\nport = 25\nuser = "u"\npass = "env(SMTP_PASS)"\nadmin_email = "a@b.dev"\n', + ), + ); + + expect(result.exitCode).toBeUndefined(); + expect(result.stdout).toContain( + '⚠ auth.email.smtp.pass SMTP_PASS is not set, so this key gets the literal text "env(SMTP_PASS)"', + ); + expect(result.stdout).toContain( + "help: set SMTP_PASS in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)", + ); + expect(result.stdout).toContain("✓ supabase/config.toml is valid · 1 warning"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("warns about a reference in a table that is enabled by default", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[auth]\nsite_url = "env(SITE_URL)"\n'), + ); + + expect(result.stdout).toContain("⚠ auth.site_url SITE_URL is not set"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("warns for a remote reference under a section the base enables", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.external.github]\nenabled = true\nclient_id = "c"\nsecret = "s"\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.auth.external.github]\nsecret = "env(GH_SECRET)"\n', + ), + ); + + expect(result.stdout).toContain("⚠ auth.external.github.secret GH_SECRET is not set"); + expect(result.stdout).toContain("[remote: prod]"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("stays quiet about unset references in a disabled table and optional keys", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.external.github]\nenabled = false\nsecret = "env(GITHUB_SECRET)"\n[studio]\nopenai_api_key = "env(OPENAI_API_KEY)"\n', + ), + ); + + expect(result.stdout).toBe("✓ supabase/config.toml is valid\n"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("does not let an unset base reference hide a remote's own error", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[api]\nmax_rows = "env(ROWS)"\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.api]\nmax_rows = "many"\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "invalid_value", key: "remotes.prod.api.max_rows" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("locates keys in a TOML file that starts with a byte order mark", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('\uFEFFproject_id = "demo"\n[db]\nmajor_version = 16\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { key: "db.major_version", line: 3, column: 17, end_line: 3, end_column: 19 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("attributes an invalid SUPABASE_* variable to the variable, not a line", () => + Effect.gen(function* () { + const result = yield* validate(tomlFile('project_id = "demo"\n[db]\nport = 54322\n'), { + env: { SUPABASE_DB_PORT: "abc" }, + }); + + expect(result.stdout).toContain(" ✗ db.port expected a port (0-65535)"); + expect(result.stdout).toContain( + "help: SUPABASE_DB_PORT (shell) sets this value, not supabase/config.toml; fix or unset it there", + ); + expect(result.stdout).not.toContain("abc"); + expect(result.stdout).not.toContain("port = 54322"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + describe("secrets never print", () => { + const SECRET = "hunter2-do-not-print"; + + const leaks = (result: Parameters[0]) => + expect(reportedTexts(result)).not.toContain(SECRET); + + it.effect("masks a secret on the context line and on a syntax error line", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile(`project_id = "demo"\n[auth.email.smtp]\npass = "${SECRET}"\nhost = "x\n`), + ); + + expect(result.stdout).toContain("pass = ****"); + leaks(result); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("masks a typo'd secret key and its neighbours", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[auth.email.smtp]\nenabled = true\nhost = "h"\nport = 25\nuser = "u"\nadmin_email = "a@b.dev"\npasss = "${SECRET}"\n`, + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "missing_required", key: "auth.email.smtp.pass" }, + { code: "unknown_key", key: "auth.email.smtp.passs", help: "did you mean `pass`?" }, + ]); + leaks(result); + const text = yield* validate( + tomlFile( + `project_id = "demo"\n[auth.email.smtp]\nenabled = true\nhost = "h"\nport = 25\nuser = "u"\nadmin_email = "a@b.dev"\npasss = "${SECRET}"\n`, + ), + ); + expect(text.stdout).toContain("passs = ****"); + leaks(text); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("masks a custom provider secret, db.vault and a multi-line secret", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[auth.external.mycorp]\nenabled = true\nsecret = "${SECRET}"\nbogus = 1\n[db.vault]\nk = "${SECRET}"\n[auth.hook.send_email]\nenabled = true\nuri = "https://x.test"\nsecrets = """${SECRET}\n${SECRET}-line2"""\nbogus2 = 1\n`, + ), + ); + + expect(result.stdout).toContain("auth.hook.send_email.secrets"); + leaks(result); + expect(result.stdout).not.toContain("line2"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("masks db.root_key and a hook secret in JSON mode and in JSONC frames", () => + Effect.gen(function* () { + const result = yield* validate({ + "supabase/config.jsonc": `{\n "project_id": "demo",\n "db": { "root_key": "${SECRET}", "major_version": 16 }\n}\n`, + }); + + expect(result.stdout).toContain("db.major_version"); + leaks(result); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("does not print a configured value in any message", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[auth.hook.send_email]\nenabled = true\nuri = "https://u:${SECRET}@x.test"\nsecrets = "v1,whsec_${SECRET}"\n[studio]\napi_url = "http://[${SECRET}"\n`, + ), + { format: "json" }, + ); + + leaks(result); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); + + it.effect("fails with the missing-project message when there is no config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(tempRoot.current, { recursive: true }); + const result = yield* validate({}); + + expect(result.failure).toMatchObject({ _tag: "ConfigValidateLoadError" }); + expect(result.failure?.message).toContain("file not found"); + expect(result.flushed).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects -o/--output in favour of --output-format", () => + Effect.gen(function* () { + const result = yield* validate(tomlFile('project_id = "demo"\n'), { outputFlag: "json" }); + + expect(result.failure).toMatchObject({ + _tag: "ConfigValidateOutputFlagUnsupportedError", + message: + "the -o/--output flag is not supported by config validate; use --output-format json|stream-json instead.", + }); + expect(result.stdout).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("never reads the linked project ref", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tempRoot.current, "supabase", ".temp", "project-ref"), { + recursive: true, + }); + const result = yield* validate(tomlFile('project_id = "demo"\n')); + + expect(result.failure).toBeUndefined(); + expect(result.stdout).toBe("✓ supabase/config.toml is valid\n"); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts new file mode 100644 index 0000000000..4ab98cca7c --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -0,0 +1,217 @@ +import { ENV_CAPTURE_REGEX, isSecretPath, type CliConfigLocation } from "@supabase/config/internal"; + +import { cliConfigRegistry } from "../../../config/cli-config-keys.ts"; + +type ProblemCode = + | "syntax" + | "file_conflict" + | "schema_version" + | "removed_key" + | "unknown_key" + | "invalid_type" + | "invalid_enum" + | "invalid_value" + | "missing_required" + | "remote_project_id" + | "env_file" + | "removed_env" + | "env_unresolved" + | "deprecated_key" + | "shadowed_file" + | "unrecognized_schema"; + +export interface ConfigProblem { + readonly code: ProblemCode; + readonly severity: "error" | "warning"; + /** The key's path with any `remotes..` prefix removed. */ + readonly path?: string; + readonly remote?: string; + /** Names the file a pathless problem is about when it is not the validated one. */ + readonly subject?: string; + /** What is wrong, short and free of configured values. */ + readonly reason: string; + readonly help?: string; + readonly location?: CliConfigLocation; + /** The value comes from the environment, so the config file has no location for it. */ + readonly external?: true; + /** The source line may print the value at `location`; every other line is masked. */ + readonly showValue: boolean; +} + +/** Splits a `remotes..…` path into the remote and the path inside it. */ +export function splitRemotePath(path: ReadonlyArray): { + readonly remote?: string; + readonly path: ReadonlyArray; +} { + const [head, name] = path; + return head === "remotes" && name !== undefined + ? { remote: name, path: path.slice(2) } + : { path }; +} + +const ROOT_KEY_PATH = "db.root_key"; +const SECRET_LOOKING = /secret|pass|token|key|jwt|salt|credential|vault/i; + +const publicLeafPaths: ReadonlySet = new Set( + cliConfigRegistry.keys + .filter((key) => key.document !== false && key.secret !== true) + .map((key) => key.path), +); + +const publicFamilyLeaves = cliConfigRegistry.families.map((family) => ({ + prefix: family.prefix.split("."), + fields: new Set( + family.fields.filter((field) => field.secret !== true).map((field) => field.name), + ), +})); + +const isPublicFamilyLeaf = (segments: ReadonlyArray): boolean => + publicFamilyLeaves.some( + ({ prefix, fields }) => + segments.length === prefix.length + 2 && + prefix.every((segment, index) => segment === segments[index]) && + fields.has(segments.at(-1) ?? ""), + ); + +/** + * Whether the value at a config key path may be printed: a schema leaf that is neither secret nor + * secret-looking. Everything else, including keys the schema does not know, is masked. + */ +export function isPublicValuePath(path: string): boolean { + const segments = splitRemotePath(path.split(".")).path; + const local = segments.join("."); + return ( + local !== ROOT_KEY_PATH && + !isSecretPath(segments) && + !SECRET_LOOKING.test(segments.at(-1) ?? "") && + (publicLeafPaths.has(local) || isPublicFamilyLeaf(segments)) + ); +} + +const keyOf = (problem: ConfigProblem): string => + `${problem.code}\0${problem.remote ?? ""}\0${problem.path ?? ""}\0${problem.subject ?? ""}`; + +const KEYED_BY_PATH: ReadonlySet = new Set([ + "invalid_type", + "invalid_enum", + "invalid_value", + "missing_required", +]); + +/** + * Removes what the remote passes repeat from the base pass, and keeps one problem per key for the + * value-level codes, so a key a later pass reports again appears once. A remote problem on a key + * the remote block writes itself is that remote's own, even when the base reports the same one. + */ +export function dedupeProblems( + problems: ReadonlyArray, + remoteWrites: (remote: string, path: string) => boolean, +): ReadonlyArray { + const base = new Set( + problems + .filter((problem) => problem.remote === undefined) + .map((problem) => `${keyOf(problem)}\0${problem.reason}`), + ); + const seen = new Set(); + return problems.filter((problem) => { + const full = `${keyOf(problem)}\0${problem.reason}`; + const inherited = + problem.remote !== undefined && + base.has(`${keyOf({ ...problem, remote: undefined })}\0${problem.reason}`) && + !remoteWrites(problem.remote, problem.path ?? ""); + if (inherited) return false; + const identity = KEYED_BY_PATH.has(problem.code) ? keyOf(problem) : full; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} + +const atOrBelow = (path: ReadonlyArray, prefix: ReadonlyArray): boolean => + prefix.length <= path.length && prefix.every((segment, index) => segment === path[index]); + +/** Drops problems at or below a key the load pruned or left out, which only exist because of that removal. */ +export function withoutFollowOns( + problems: ReadonlyArray, + removed: ReadonlyArray>, +): ReadonlyArray { + return problems.filter( + (problem) => + problem.path === undefined || + !removed.some((prefix) => atOrBelow(problem.path?.split(".") ?? [], prefix)), + ); +} + +export interface EnvReference { + readonly name: string; + readonly remote?: string; + readonly path: ReadonlyArray; +} + +/** Every whole-value `env(NAME)` string in a raw document, with the key path that holds it. */ +export function envReferences(document: unknown): ReadonlyArray { + const found: Array = []; + const walk = (value: unknown, path: ReadonlyArray): void => { + if (typeof value === "string") { + const name = ENV_CAPTURE_REGEX.exec(value)?.[1]; + if (name === undefined) return; + const split = splitRemotePath(path); + found.push({ + name, + path: split.path, + ...(split.remote === undefined ? {} : { remote: split.remote }), + }); + } else if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, [...path, String(index)])); + } else if (typeof value === "object" && value !== null) { + for (const [key, item] of Object.entries(value)) walk(item, [...path, key]); + } + }; + walk(document, []); + return found; +} + +/** + * Drops errors that sit at or below a key whose `env(NAME)` reference could not be resolved in the + * same pass, and names the references that caused a drop. + */ +export function withoutUnresolvedEnvErrors( + problems: ReadonlyArray, + unresolved: ReadonlyArray, +): { readonly kept: ReadonlyArray; readonly blamed: ReadonlyArray } { + const blamed = new Set(); + const kept = problems.filter((problem) => { + if (problem.severity !== "error" || problem.path === undefined) return true; + const segments = problem.path.split("."); + const owners = unresolved.filter( + (reference) => reference.remote === problem.remote && atOrBelow(segments, reference.path), + ); + for (const owner of owners) blamed.add(owner); + return owners.length === 0; + }); + return { kept, blamed: [...blamed] }; +} + +const readPath = (root: unknown, path: ReadonlyArray): unknown => { + let node = root; + for (const segment of path) { + if (typeof node !== "object" || node === null) return undefined; + node = Reflect.get(node, segment); + } + return node; +}; + +/** + * The value of the nearest enclosing table's `enabled` switch on an owning pass's resolved + * config, or `undefined` when no enclosing table has one. + */ +export function nearestEnabledGate( + config: unknown, + path: ReadonlyArray, +): boolean | undefined { + for (let depth = path.length - 1; depth > 0; depth--) { + const enabled = readPath(config, [...path.slice(0, depth), "enabled"]); + if (typeof enabled === "boolean") return enabled; + } + return undefined; +} diff --git a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts new file mode 100644 index 0000000000..df356eb721 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { + type ConfigProblem, + dedupeProblems, + envReferences, + isPublicValuePath, + nearestEnabledGate, + splitRemotePath, + withoutFollowOns, + withoutUnresolvedEnvErrors, +} from "./validate.model.ts"; + +const problem = (fields: Partial & Pick): ConfigProblem => ({ + severity: "error", + reason: "bad", + showValue: false, + ...fields, +}); + +const noRemoteWrites = () => false; + +describe("splitRemotePath", () => { + it("separates the remote name from the path inside the remote", () => { + expect(splitRemotePath(["remotes", "staging", "db", "port"])).toEqual({ + remote: "staging", + path: ["db", "port"], + }); + }); + + it("leaves a base path alone", () => { + expect(splitRemotePath(["db", "port"])).toEqual({ path: ["db", "port"] }); + }); +}); + +describe("isPublicValuePath", () => { + it.each([ + "db.port", + "db.major_version", + "auth.external.github.client_id", + "remotes.staging.db.port", + ])("shows the value of %s", (path) => { + expect(isPublicValuePath(path)).toBe(true); + }); + + it.each([ + "db.root_key", + "db.vault.token", + "edge_runtime.secrets.API_KEY", + "auth.external.github.secret", + "auth.email.smtp.pass", + "auth.hook.send_email.secrets", + "api.not_a_key", + ])("masks the value of %s", (path) => { + expect(isPublicValuePath(path)).toBe(false); + }); +}); + +describe("dedupeProblems", () => { + it("drops a remote problem the base already reports", () => { + const base = problem({ code: "invalid_value", path: "db.port", reason: "expected a port" }); + const remote = { ...base, remote: "staging" }; + expect(dedupeProblems([base, remote], noRemoteWrites)).toEqual([base]); + }); + + it("keeps a remote problem on a key the remote writes itself", () => { + const base = problem({ code: "invalid_value", path: "db.port", reason: "expected a port" }); + const remote = { ...base, remote: "staging" }; + expect( + dedupeProblems([base, remote], (name, path) => name === "staging" && path === "db.port"), + ).toEqual([base, remote]); + }); + + it("reports a value-level key once even when the reason differs", () => { + const first = problem({ code: "invalid_value", path: "db.port", reason: "one" }); + const second = problem({ code: "invalid_value", path: "db.port", reason: "two" }); + expect(dedupeProblems([first, second], noRemoteWrites)).toEqual([first]); + }); + + it("keeps the same key reported under different remotes", () => { + const a = problem({ code: "invalid_value", path: "db.port", remote: "a" }); + const b = problem({ code: "invalid_value", path: "db.port", remote: "b" }); + expect(dedupeProblems([a, b], (name) => name !== "")).toEqual([a, b]); + }); +}); + +describe("withoutFollowOns", () => { + it("drops problems at or below a pruned path and keeps the rest", () => { + const below = problem({ code: "missing_required", path: "auth.external.github.client_id" }); + const at = problem({ code: "invalid_value", path: "auth.external.github" }); + const other = problem({ code: "invalid_value", path: "auth.external.gitlab.client_id" }); + const pathless = problem({ code: "removed_env" }); + expect( + withoutFollowOns([below, at, other, pathless], [["auth", "external", "github"]]), + ).toEqual([other, pathless]); + }); +}); + +describe("envReferences", () => { + it("finds whole-value references with their key path and remote", () => { + const references = envReferences({ + auth: { site_url: "env(SITE)", note: "see env(IGNORED) here" }, + remotes: { prod: { api: { max_rows: "env(ROWS)" } } }, + list: ["env(FIRST)"], + }); + + expect(references).toEqual([ + { name: "SITE", path: ["auth", "site_url"] }, + { name: "ROWS", path: ["api", "max_rows"], remote: "prod" }, + { name: "FIRST", path: ["list", "0"] }, + ]); + }); +}); + +describe("withoutUnresolvedEnvErrors", () => { + const baseRef = { name: "A", path: ["api"] }; + const remoteRef = { name: "B", path: ["api"], remote: "prod" }; + + it("drops errors at or below an unresolved reference of the same pass", () => { + const base = problem({ code: "invalid_value", path: "api.max_rows" }); + const remote = problem({ code: "invalid_value", path: "api.max_rows", remote: "prod" }); + const result = withoutUnresolvedEnvErrors([base, remote], [baseRef]); + + expect(result.kept).toEqual([remote]); + expect(result.blamed).toEqual([baseRef]); + }); + + it("lets a remote reference drop only that remote's errors", () => { + const base = problem({ code: "invalid_value", path: "api.max_rows" }); + const remote = problem({ code: "invalid_value", path: "api.max_rows", remote: "prod" }); + + expect(withoutUnresolvedEnvErrors([base, remote], [remoteRef]).kept).toEqual([base]); + }); + + it("never drops warnings", () => { + const warning = problem({ code: "deprecated_key", severity: "warning", path: "api.max_rows" }); + expect(withoutUnresolvedEnvErrors([warning], [baseRef]).kept).toEqual([warning]); + }); +}); + +describe("nearestEnabledGate", () => { + const config = { + auth: { enabled: true, external: { github: { enabled: false, secret: "x" } }, site_url: "u" }, + db: { port: 1 }, + }; + + it("uses the nearest enclosing table that has an enabled switch", () => { + expect(nearestEnabledGate(config, ["auth", "external", "github", "secret"])).toBe(false); + expect(nearestEnabledGate(config, ["auth", "site_url"])).toBe(true); + }); + + it("is undefined when no enclosing table has one", () => { + expect(nearestEnabledGate(config, ["db", "port"])).toBeUndefined(); + expect(nearestEnabledGate(undefined, ["db", "port"])).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index 0562dbc514..2d78bc19e5 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -158,6 +158,15 @@ const SMS_PROVIDER_SECTIONS: ReadonlySet = new Set([ "vonage", ]); +/** Keys whose `env(NAME)` reference may stay unset without harm, with the reason; `supabase init` writes these. */ +export const CLI_CONFIG_OPTIONAL_ENV_PATHS: Readonly> = { + "studio.openai_api_key": "Studio treats the key as optional", + "experimental.s3_host": "only read when S3 storage is configured", + "experimental.s3_region": "only read when S3 storage is configured", + "experimental.s3_access_key": "only read when S3 storage is configured", + "experimental.s3_secret_key": "only read when S3 storage is configured", +}; + /** Optional schema sections whose env overrides apply while the section is absent, with the reason. */ export const CLI_CONFIG_SECTION_ENV_EXEMPT: Readonly> = { "auth.sessions": diff --git a/apps/cli/src/config/cli-config-key.ts b/apps/cli/src/config/cli-config-key.ts index 7135734cd5..b2cac41204 100644 --- a/apps/cli/src/config/cli-config-key.ts +++ b/apps/cli/src/config/cli-config-key.ts @@ -413,6 +413,7 @@ const invalidCliConfigValue = ( tier: "flag", source, flag: origin.flag, + expected, message: `Invalid --${origin.flag}="${raw}" (sets ${path}): expected ${expected}.`, }); case "shell": @@ -426,6 +427,7 @@ const invalidCliConfigValue = ( tier: origin.tier, source, envName: origin.envName, + expected, message: `Invalid ${origin.envName}="${raw}"${file} (sets ${path}): expected ${expected}.`, }); } @@ -435,6 +437,7 @@ const invalidCliConfigValue = ( path, tier: "config", source, + expected, message: typeOnly ? `Invalid ${path}${where === "config" ? "" : ` in ${where}`}: expected ${expected}, got ${raw}.` : where === "config" @@ -487,6 +490,7 @@ export const pickCliConfigKey = ( new CliConfigValueError({ path, tier: "shell", + expected: entry.codec.expected, source: `${entry.name} (shell)`, message: `Invalid ${entry.name}="${raw}" (sets ${path}): expected ${entry.codec.expected}.`, envName: entry.name, diff --git a/apps/cli/src/config/cli-config-remote.ts b/apps/cli/src/config/cli-config-remote.ts index 062c26d122..42d83a3bce 100644 --- a/apps/cli/src/config/cli-config-remote.ts +++ b/apps/cli/src/config/cli-config-remote.ts @@ -38,21 +38,28 @@ export const selectCliConfigRemote = ( )?.name; }; -/** - * The load failure for a duplicate or malformed remote `project_id`, if any. Runs on every load, - * regardless of which remote is selected. - */ -export const cliConfigRemoteFailure = ( +export interface CliConfigRemoteFailure { + readonly remote: string; + readonly message: string; +} + +/** Every remote with a duplicate or malformed `project_id`, one failure per remote,. */ +export const cliConfigRemoteFailures = ( remotes: Readonly>, lookup: EnvLookup, -): string | undefined => { +): ReadonlyArray => { + const failures = new Map(); const seen = new Map(); for (const { name, block } of remoteBlocks(remotes)) { const projectId = matchProjectId(name, block, lookup); if (projectId === undefined) continue; const prior = seen.get(projectId); if (prior !== undefined) { - return `duplicate project_id for [remotes.${name}] and [remotes.${prior}]`; + failures.set(name, { + remote: name, + message: `duplicate project_id for [remotes.${name}] and [remotes.${prior}]`, + }); + continue; } seen.set(projectId, name); } @@ -62,9 +69,12 @@ export const cliConfigRemoteFailure = ( const projectId = override ?? (literal === undefined ? undefined : expandCliConfigEnvReference(literal, lookup)); - if (projectId === undefined || !PROJECT_REF_PATTERN.test(projectId)) { - return `Invalid config for remotes.${name}.project_id. Must be like: abcdefghijklmnopqrst`; + if ((projectId === undefined || !PROJECT_REF_PATTERN.test(projectId)) && !failures.has(name)) { + failures.set(name, { + remote: name, + message: `Invalid config for remotes.${name}.project_id. Must be like: abcdefghijklmnopqrst`, + }); } } - return undefined; + return [...failures.values()]; }; diff --git a/apps/cli/src/config/cli-config-schema-issues.ts b/apps/cli/src/config/cli-config-schema-issues.ts new file mode 100644 index 0000000000..c9277f4e63 --- /dev/null +++ b/apps/cli/src/config/cli-config-schema-issues.ts @@ -0,0 +1,88 @@ +import { Schema, SchemaIssue } from "effect"; + +type SchemaIssueCode = "invalid_type" | "invalid_enum" | "invalid_value" | "missing_required"; + +/** One leaf of a schema decode failure; `reason` and `help` never quote the offending input. */ +export interface FlatSchemaIssue { + readonly path: ReadonlyArray; + readonly code: SchemaIssueCode; + readonly reason: string; + readonly help?: string; +} + +const TAG = "\u0000"; + +const typeReason = (expected: string | undefined): string => { + switch (expected) { + case "Boolean": + return "expected a boolean"; + case "Number": + return "expected a number"; + case "String": + return "expected a string"; + case "Objects": + return "expected a table"; + case "Arrays": + return "expected an array"; + default: + return "wrong type"; + } +}; + +const MISSING_FIELD = /^Missing required field in config: /; + +/** A schema-authored message is shown only when it quotes nothing, so it cannot carry the input. */ +const safeLeafMessage = (message: string): string => + MISSING_FIELD.test(message) + ? "missing required key" + : /["'`]/.test(message) + ? "invalid value" + : message.replace(/, got [\s\S]*$/, ""); + +const leafMessage: SchemaIssue.LeafHook = (leaf) => { + if (leaf._tag === "MissingKey") return `${TAG}missing_required${TAG}`; + if (leaf._tag === "InvalidType") { + return `${TAG}invalid_type${TAG}${typeReason(leaf.ast._tag)}`; + } + return `${TAG}invalid_value${TAG}${safeLeafMessage(SchemaIssue.defaultLeafHook(leaf))}`; +}; + +const ENUM_MESSAGE = /^Expected ((?:"[^"]*"(?: \| )?)+)$/; + +function classify(message: string): Omit { + if (message.startsWith(TAG)) { + const [, code, reason] = message.split(TAG); + if (code === "missing_required") { + return { code, reason: "missing required key", help: "add the key" }; + } + if (code === "invalid_type") return { code, reason: reason ?? "wrong type" }; + if (reason === "missing required key") { + return { code: "missing_required", reason, help: "add the key" }; + } + return { code: "invalid_value", reason: reason ?? "invalid value" }; + } + const choices = ENUM_MESSAGE.exec(message)?.[1] + ?.split(" | ") + .map((item) => item.slice(1, -1)); + if (choices !== undefined) { + const list = choices.join(", "); + return { code: "invalid_enum", reason: `expected one of ${list}`, help: `use one of ${list}` }; + } + const first = message.slice(0, 1).toLowerCase() + message.slice(1); + return { code: "invalid_value", reason: first.replace(/, got [\s\S]*$/, "") }; +} + +const segmentOf = (segment: PropertyKey | { readonly key: PropertyKey }): string => + String(typeof segment === "object" ? segment.key : segment); + +/** Every leaf a schema decode failure reports, with its key path. */ +export function flattenSchemaIssues(cause: unknown): ReadonlyArray { + if (!Schema.isSchemaError(cause)) return []; + const { issues } = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: leafMessage })( + cause.issue, + ); + return issues.map((issue) => ({ + path: (issue.path ?? []).map(segmentOf), + ...classify(issue.message), + })); +} diff --git a/apps/cli/src/config/cli-config-schema-issues.unit.test.ts b/apps/cli/src/config/cli-config-schema-issues.unit.test.ts new file mode 100644 index 0000000000..384e257535 --- /dev/null +++ b/apps/cli/src/config/cli-config-schema-issues.unit.test.ts @@ -0,0 +1,69 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { flattenSchemaIssues } from "./cli-config-schema-issues.ts"; + +const schemaError = (schema: Schema.Top, value: unknown) => { + try { + Schema.decodeUnknownSync(schema as Schema.Decoder)(value, { errors: "all" }); + } catch (cause) { + return cause; + } + throw new Error("expected the decode to fail"); +}; + +const schema = Schema.Struct({ + flag: Schema.Boolean, + count: Schema.Finite, + mode: Schema.Literals(["fast", "slow"]), + rows: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 })), + list: Schema.Array(Schema.String), + table: Schema.Struct({ inner: Schema.Finite }), + secret: Schema.String, +}); + +describe("flattenSchemaIssues", () => { + const flat = flattenSchemaIssues( + schemaError(schema, { + flag: "hunter2", + count: "hunter2", + mode: "hunter2", + rows: 99, + list: [1], + table: "hunter2", + }), + ); + + it("codes each leaf by what is wrong and keeps its path", () => { + expect(flat.map(({ path, code }) => [path.join("."), code])).toEqual([ + ["flag", "invalid_type"], + ["count", "invalid_type"], + ["mode", "invalid_enum"], + ["rows", "invalid_value"], + ["list.0", "invalid_type"], + ["table", "invalid_type"], + ["secret", "missing_required"], + ]); + }); + + it("words each reason without quoting the input", () => { + expect(flat.map(({ reason }) => reason)).toEqual([ + "expected a boolean", + "expected a number", + "expected one of fast, slow", + "expected a value between 1 and 5", + "expected a string", + "expected a table", + "missing required key", + ]); + expect(JSON.stringify(flat)).not.toContain("hunter2"); + }); + + it("offers the allowed values for an enum", () => { + expect(flat[2]?.help).toBe("use one of fast, slow"); + }); + + it("returns nothing for a cause that is not a schema error", () => { + expect(flattenSchemaIssues(new Error("boom"))).toEqual([]); + }); +}); diff --git a/apps/cli/src/config/cli-config-unknown-keys.ts b/apps/cli/src/config/cli-config-unknown-keys.ts new file mode 100644 index 0000000000..0f7be45b51 --- /dev/null +++ b/apps/cli/src/config/cli-config-unknown-keys.ts @@ -0,0 +1,89 @@ +import { findUnknownCliConfigKeys } from "@supabase/config/internal"; + +import { cliConfigDocumentOnlyPaths } from "./cli-config-keys.ts"; +import { getDocumentValue, isDocumentRecord } from "./cli-config-document.ts"; + +const MAX_SUGGESTION_DISTANCE = 2; + +/** An unknown key's path in the document and the keys its parent table accepts. */ +export interface ReportableUnknownCliConfigKey { + readonly path: ReadonlyArray; + readonly knownKeys: ReadonlyArray; +} + +const withoutRemote = (key: ReadonlyArray): ReadonlyArray => + key[0] === "remotes" ? key.slice(2) : key; + +/** Unknown-key reports stop at the first unknown table, so a document-only table such as `auth.passkey` arrives whole. */ +const isDocumentOnlyKey = (key: ReadonlyArray): boolean => { + const dotted = key.join("."); + return [...cliConfigDocumentOnlyPaths].some( + (known) => known === dotted || known.startsWith(`${dotted}.`) || dotted.startsWith(`${known}.`), + ); +}; + +/** Custom `[auth.external.]` providers have no schema entry, but `start` reads them from the raw document. */ +const isCustomAuthProvider = (rawDocument: unknown, key: ReadonlyArray): boolean => { + const local = withoutRemote(key); + return ( + local.length === 3 && + local[0] === "auth" && + local[1] === "external" && + isDocumentRecord(getDocumentValue(rawDocument, key)) + ); +}; + +const documentOnlySiblings = (key: ReadonlyArray): ReadonlyArray => { + const parent = withoutRemote(key).slice(0, -1); + return [...cliConfigDocumentOnlyPaths].flatMap((known) => { + const segments = known.split("."); + const sibling = segments[parent.length]; + return sibling !== undefined && parent.every((segment, index) => segments[index] === segment) + ? [sibling] + : []; + }); +}; + +/** Unknown keys the CLI ignores and should flag, minus document-only tables and custom auth providers. */ +export function reportableUnknownCliConfigKeys( + document: unknown, +): ReadonlyArray { + return findUnknownCliConfigKeys(document) + .filter( + ({ path }) => + !isDocumentOnlyKey(withoutRemote(path)) && !isCustomAuthProvider(document, path), + ) + .map(({ path, knownKeys }) => ({ + path, + knownKeys: [...new Set([...knownKeys, ...documentOnlySiblings(path)])], + })); +} + +function editDistance(left: string, right: string): number { + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let row = 1; row <= left.length; row += 1) { + const current = [row]; + for (let column = 1; column <= right.length; column += 1) { + const substitution = + (previous[column - 1] ?? 0) + (left[row - 1] === right[column - 1] ? 0 : 1); + current.push( + Math.min(substitution, (previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1), + ); + } + previous = current; + } + return previous[right.length] ?? 0; +} + +/** The accepted key closest to a misspelled `key` within two edits, or undefined when none or several tie. */ +export function suggestCliConfigKey( + key: string, + knownKeys: ReadonlyArray, +): string | undefined { + const scored = knownKeys + .map((candidate) => ({ candidate, distance: editDistance(key, candidate) })) + .filter(({ distance }) => distance <= MAX_SUGGESTION_DISTANCE && distance < key.length); + const best = Math.min(...scored.map(({ distance }) => distance)); + const closest = scored.filter(({ distance }) => distance === best); + return closest.length === 1 ? closest[0]?.candidate : undefined; +} diff --git a/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts b/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts new file mode 100644 index 0000000000..ab8ac5a26f --- /dev/null +++ b/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { reportableUnknownCliConfigKeys, suggestCliConfigKey } from "./cli-config-unknown-keys.ts"; + +describe("reportableUnknownCliConfigKeys", () => { + it("reports an unknown key with the keys its table accepts", () => { + const [found] = reportableUnknownCliConfigKeys({ api: { prot: 1 } }); + + expect(found?.path).toEqual(["api", "prot"]); + expect(found?.knownKeys).toContain("port"); + }); + + it("ignores document-only keys at the root and in a remote", () => { + expect( + reportableUnknownCliConfigKeys({ + db: { password: "pw", root_key: "rk" }, + remotes: { staging: { db: { password: "pw" } } }, + }), + ).toEqual([]); + }); + + it("ignores custom auth providers", () => { + expect( + reportableUnknownCliConfigKeys({ auth: { external: { my_oidc: { enabled: true } } } }), + ).toEqual([]); + }); + + it("offers document-only tables as siblings of an unknown auth key", () => { + const [found] = reportableUnknownCliConfigKeys({ auth: { passkeys: { enabled: true } } }); + + expect(found?.path).toEqual(["auth", "passkeys"]); + expect(found?.knownKeys).toContain("passkey"); + }); +}); + +describe("suggestCliConfigKey", () => { + it("suggests the closest accepted key", () => { + expect(suggestCliConfigKey("prot", ["port", "enabled", "schemas"])).toBe("port"); + }); + + it("suggests nothing beyond two edits", () => { + expect(suggestCliConfigKey("completely_different", ["port", "enabled"])).toBeUndefined(); + }); + + it("suggests nothing when two keys tie for closest", () => { + expect(suggestCliConfigKey("pot", ["port", "post"])).toBeUndefined(); + }); + + it("suggests nothing when there are no candidates", () => { + expect(suggestCliConfigKey("prot", [])).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/config/cli-config-values.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts index 18c152e1d4..9ba5636b7b 100644 --- a/apps/cli/src/config/cli-config-values.integration.test.ts +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -2536,4 +2536,81 @@ describe("CliConfigValues failure telemetry", () => { }), ).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), ); + + describe("validation loads", () => { + const REMOTE_ID = "stagingrefstagingref"; + const remoteConfig = `project_id = "declared" +[db] +port = 5000 +[remotes.staging] +project_id = "${REMOTE_ID}" +[remotes.staging.db] +port = 6000 +`; + + it.live("prints no notices and lists invalid values instead of failing", () => + Effect.gen(function* () { + const root = yield* project('bogus = 1\n[db]\nport = "not-a-port"\n[api]\nfoo = 2\n'); + const output = mockOutput(); + + const resolved = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none(), validation: true }), + ).pipe(Effect.provide(configValuesLayer({ output: output.layer }))); + + expect(resolved.invalid.map((entry) => entry.path)).toEqual(["db.port"]); + expect(output.stderrText).toBe(""); + expect(output.messages).toEqual([]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("selects a remote by name without a project ref", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const layer = configValuesLayer(); + + const base = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none(), validation: true }), + ).pipe(Effect.provide(layer)); + const staging = yield* CliConfigValues.use((values) => + values.load({ + workdir: root, + projectRef: Option.none(), + validation: true, + remoteName: "staging", + }), + ).pipe(Effect.provide(layer)); + + expect(base.materialized.config.db.port).toBe(5000); + expect(staging.appliedRemote).toEqual(Option.some("staging")); + expect(staging.materialized.config.db.port).toBe(6000); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("prunes past every schema failure only when validating", () => + Effect.gen(function* () { + const root = yield* project("api = 1\nstudio = 2\n"); + const layer = configValuesLayer(); + + const strict = yield* load(root).pipe(Effect.flip, Effect.provide(layer)); + const validated = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none(), validation: true }), + ).pipe(Effect.provide(layer)); + + expect(strict._tag).toBe("CliConfigParseError"); + expect(validated.pruned).toEqual([["api"], ["studio"]]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("collects a remote project_id failure with its remote name", () => + Effect.gen(function* () { + const root = yield* project('[remotes.staging]\nproject_id = "bad"\n'); + + const loaded = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none(), validation: true }), + ).pipe(Effect.provide(configValuesLayer())); + + expect(loaded.remoteFailures).toMatchObject([{ remote: "staging" }]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + }); }); diff --git a/apps/cli/src/config/cli-config-values.layer.ts b/apps/cli/src/config/cli-config-values.layer.ts index ffd102a868..b9bc047a09 100644 --- a/apps/cli/src/config/cli-config-values.layer.ts +++ b/apps/cli/src/config/cli-config-values.layer.ts @@ -1,8 +1,8 @@ import { CLI_CONFIG_SCHEMA_URL, type CliConfigValueOrigin } from "@supabase/config"; +import { CliConfigParseError } from "@supabase/config/effect"; import { decodeMergedCliConfig, ENV_CAPTURE_REGEX, - findUnknownCliConfigKeys, mergeParsedCliConfig, parseCliConfigDocumentFile, type MergedCliConfigDocument, @@ -55,14 +55,15 @@ import { } from "./cli-config-key.ts"; import { cliConfigFamilyEnvNames, - cliConfigDocumentOnlyPaths, cliConfigFamilyKey, cliConfigRegistry, cliRemoteProjectIdEnvName, staticCliConfigFamilyNames, type AnyCliConfigKey, } from "./cli-config-keys.ts"; -import { cliConfigRemoteFailure, selectCliConfigRemote } from "./cli-config-remote.ts"; +import { reportableUnknownCliConfigKeys } from "./cli-config-unknown-keys.ts"; +import { flattenSchemaIssues } from "./cli-config-schema-issues.ts"; +import { cliConfigRemoteFailures, selectCliConfigRemote } from "./cli-config-remote.ts"; import { CliConfigValues, type CliConfigMaterialized, @@ -77,6 +78,8 @@ class LoadKey extends Data.Class<{ readonly ignoreConfigFile: boolean; readonly tolerateUnreadableLinkedRef: boolean; readonly tolerateInvalid: boolean; + readonly validation: boolean; + readonly remoteName: string | undefined; }> {} const emptyMergedDocument = (workdir: string, separator: string): MergedCliConfigDocument => ({ @@ -97,25 +100,6 @@ const deprecatedKeyLine = (deprecatedPath: string): string => { return `WARN: ${shown} is deprecated and will be removed in v4, use ${shown.replace(/enable_signup$/, "enabled")} instead.\n`; }; -/** Unknown-key reports stop at the first unknown table, so a document-only table such as `auth.passkey` arrives whole. */ -const isDocumentOnlyKey = (key: ReadonlyArray): boolean => { - const dotted = key.join("."); - return [...cliConfigDocumentOnlyPaths].some( - (known) => known === dotted || known.startsWith(`${dotted}.`) || dotted.startsWith(`${known}.`), - ); -}; - -/** Custom `[auth.external.]` providers have no schema entry, but `start` reads them from the raw document. */ -const isCustomAuthProvider = (rawDocument: unknown, key: ReadonlyArray): boolean => { - const local = key[0] === "remotes" ? key.slice(2) : key; - return ( - local.length === 3 && - local[0] === "auth" && - local[1] === "external" && - isDocumentRecord(getDocumentValue(rawDocument, key)) - ); -}; - const unrecognizedSchemaLine = (file: string, ref: string): string => `WARN: ${file} declares schema ${escapeControlCharacters(ref)}, which is not the Supabase CLI config schema, so it is read as v1. Set it to ${CLI_CONFIG_SCHEMA_URL} to remove this warning.\n`; @@ -211,6 +195,31 @@ const withEnvOrigins = ): Record => { + const { remotes: _remotes, ...rest } = document; + return rest; +}; + +const MAX_PRUNE_ROUNDS = 5; + +/** The path to delete so a decode issue at `path` stops recurring: the key itself, the array holding it, or the deepest table that exists above an absent key. */ +const prunableSegments = ( + draft: Record, + path: ReadonlyArray, + missingKey: boolean, +): ReadonlyArray => { + const target = missingKey ? path.slice(0, -1) : path; + let node: unknown = draft; + const kept: Array = []; + for (const segment of target) { + if (!isDocumentRecord(node)) break; + if (!Object.hasOwn(node, segment)) break; + kept.push(segment); + node = node[segment]; + } + return kept; +}; + const materializedWrite = ( key: AnyCliConfigKey, picked: CliConfigValue, @@ -274,17 +283,32 @@ export const cliConfigValuesLayer = Layer.effect( }, ); + const validationParsedDocuments = yield* Cache.makeWith( + (workdir: string) => + withPlatform( + parseCliConfigDocumentFile(workdir, { search: false, tolerateRemovedKeys: true }), + ), + { + capacity: 8, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }, + ); + const loadResolvedConfig = Effect.fn("CliConfigValues.load")(function* (target: LoadKey) { const [conflict] = flagInputs.conflicts; if (conflict !== undefined) return yield* cliConfigFlagConflictError(conflict); const parsed = target.ignoreConfigFile ? null - : yield* Cache.get(parsedDocuments, target.workdir); - for (const deprecatedPath of parsed?.deprecatedPaths ?? []) { + : yield* Cache.get( + target.validation ? validationParsedDocuments : parsedDocuments, + target.workdir, + ); + const notices = !target.validation; + for (const deprecatedPath of notices ? (parsed?.deprecatedPaths ?? []) : []) { yield* printOnce(deprecatedKeyLine(deprecatedPath)); } - if (parsed !== null) { + if (parsed !== null && notices) { const displayPath = (filePath: string) => path.relative(target.workdir, filePath).replaceAll("\\", "/"); const used = displayPath(parsed.path); @@ -292,13 +316,9 @@ export const cliConfigValuesLayer = Layer.effect( if (declaration !== undefined && declaration.version === undefined) { yield* printOnce(unrecognizedSchemaLine(used, declaration.ref)); } - const unknownKeys = findUnknownCliConfigKeys(parsed.rawDocument) - .filter( - (key) => - !isDocumentOnlyKey(key[0] === "remotes" ? key.slice(2) : key) && - !isCustomAuthProvider(parsed.rawDocument, key), - ) - .map((key) => key.join(".")); + const unknownKeys = reportableUnknownCliConfigKeys(parsed.rawDocument).map( + ({ path: key }) => key.join("."), + ); if (unknownKeys.length > 0) { yield* printOnce(unknownKeysLine(used, unknownKeys)); } @@ -381,9 +401,12 @@ export const cliConfigValuesLayer = Layer.effect( } const remotes = parsed?.rawDocument?.["remotes"]; - if (isDocumentRecord(remotes)) { - const failure = cliConfigRemoteFailure(remotes, lookupEnv); - if (failure !== undefined) return yield* new CliConfigLoadError({ message: failure }); + const remoteFailures = isDocumentRecord(remotes) + ? cliConfigRemoteFailures(remotes, lookupEnv) + : []; + const [firstRemoteFailure] = remoteFailures; + if (firstRemoteFailure !== undefined && !target.validation) { + return yield* new CliConfigLoadError(firstRemoteFailure); } const merged = @@ -391,6 +414,7 @@ export const cliConfigValuesLayer = Layer.effect( ? null : yield* mergeParsedCliConfig(parsed, { selectRemote: (candidates) => + target.remoteName ?? selectCliConfigRemote(candidates, target.projectRef, lookupEnv), }); @@ -400,7 +424,12 @@ export const cliConfigValuesLayer = Layer.effect( "config.env_withheld": Option.isSome(foreignLinkedRef), }); - const document = merged?.document; + const document = + target.validation && + merged?.appliedRemote === undefined && + isDocumentRecord(merged?.document) + ? withoutRemotes(merged.document) + : merged?.document; const remoteLeaves = new Set(merged?.remoteLeafPaths.map((leaf) => leaf.join("."))); const localLeaves = documentLeafPaths(merged?.rawDocument ?? {}); const appliedRemote = merged?.appliedRemote; @@ -547,16 +576,51 @@ export const cliConfigValuesLayer = Layer.effect( if (value !== undefined) envValues[name] = value; } + const allIssues = target.validation ? ({ allIssues: true } as const) : {}; const mergedForDecode = merged ?? emptyMergedDocument(target.workdir, path.sep); + const schemaFailures: Array = []; + const pruned: Array> = []; + const decode = ( + draft: Record, + resolvedPaths: ReadonlyArray>, + reportFailures: boolean, + ) => + Effect.gen(function* () { + for (let round = 0; ; round++) { + const result = yield* withPlatform( + decodeMergedCliConfig(mergedForDecode, { + envValues, + cliCompat: true, + document: draft, + resolvedPaths, + ...allIssues, + }), + ).pipe(Effect.result); + if (Result.isSuccess(result)) return result.success; + const error = result.failure; + if ( + !target.validation || + !(error instanceof CliConfigParseError) || + round >= MAX_PRUNE_ROUNDS + ) { + return yield* error; + } + const targets = flattenSchemaIssues(error.cause) + .map((issue) => + prunableSegments(draft, issue.path, issue.code === "missing_required"), + ) + .filter((segments) => segments.length > 0); + if (targets.length === 0) return yield* error; + if (reportFailures) schemaFailures.push(error); + for (const segments of targets) { + if (!pruned.some((known) => sameDocumentValue(known, segments))) + pruned.push(segments); + } + pruneDocumentPaths(draft, targets, document); + } + }); const loaded = withEnvOrigins( - yield* withPlatform( - decodeMergedCliConfig(mergedForDecode, { - envValues, - cliCompat: true, - document: declaredDraft, - resolvedPaths: declaredResolved, - }), - ), + yield* decode(declaredDraft, declaredResolved, true), declaredEnvOrigins, ); const fileDeclared = withPlatform( @@ -565,17 +629,11 @@ export const cliConfigValuesLayer = Layer.effect( cliCompat: true, document: fileDraft, resolvedPaths: fileResolved, + ...allIssues, }), ).pipe(Effect.map((decoded) => withEnvOrigins(decoded, fileEnvOrigins))); const materializedLoaded = withEnvOrigins( - yield* withPlatform( - decodeMergedCliConfig(mergedForDecode, { - envValues, - cliCompat: true, - document: materializedDraft, - resolvedPaths: materializedResolved, - }), - ), + yield* decode(materializedDraft, materializedResolved, false), materializedEnvOrigins, ); @@ -607,7 +665,7 @@ export const cliConfigValuesLayer = Layer.effect( return picked.success; }).pipe(tracksConfigFailureKey); - for (const message of overrideWarnings) yield* warnOnce(message); + if (notices) for (const message of overrideWarnings) yield* warnOnce(message); if (Option.isSome(debugLogger)) { for (const [originPath, origin] of origins) { if (origin.tier === "default") continue; @@ -626,6 +684,9 @@ export const cliConfigValuesLayer = Layer.effect( materialized, origins, invalid, + remoteFailures: target.validation ? remoteFailures : [], + schemaFailures, + pruned, familyNames, declares: (configPath) => configAt(configPath) !== undefined, withheldEnv, @@ -669,7 +730,9 @@ export const cliConfigValuesLayer = Layer.effect( projectRef: target.projectRef, ignoreConfigFile: target.ignoreConfigFile === true, tolerateUnreadableLinkedRef: target.tolerateUnreadableLinkedRef === true, - tolerateInvalid: target.tolerateInvalid === true, + tolerateInvalid: target.tolerateInvalid === true || target.validation === true, + validation: target.validation === true, + remoteName: target.remoteName, }), ).pipe(tracksConfigFailureKey), writeThrough: (write) => Effect.ensuring(write, invalidate), diff --git a/apps/cli/src/config/cli-config-values.service.ts b/apps/cli/src/config/cli-config-values.service.ts index aa3c76c729..3aaea0db55 100644 --- a/apps/cli/src/config/cli-config-values.service.ts +++ b/apps/cli/src/config/cli-config-values.service.ts @@ -9,6 +9,8 @@ import { Context } from "effect"; import type { LinkedProjectRefReadError } from "../shared/config/project-link.ts"; import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; +import type { CliConfigParseError } from "@supabase/config/effect"; +import type { CliConfigRemoteFailure } from "./cli-config-remote.ts"; import type { CliConfigFamilyId } from "./cli-config-key-annotations.ts"; import type { CliConfigFlagDeclaration } from "./cli-config-flags.ts"; import type { CliConfigKey, CliConfigKeyOrigin, CliConfigValue } from "./cli-config-key.ts"; @@ -28,6 +30,10 @@ interface CliConfigLoadTarget { readonly tolerateUnreadableLinkedRef?: true; /** Leaves an invalid value out of the resolved config and lists it in `invalid`, instead of failing the load. */ readonly tolerateInvalid?: true; + /** Selects `[remotes.]` by name instead of by `projectRef`; without a `projectRef` no linked ref is read. */ + readonly remoteName?: string; + /** Prints no notices, decodes with every schema issue reported, and implies `tolerateInvalid`. */ + readonly validation?: true; } /** An env variable that held a value but was withheld because it belongs to the linked project. */ @@ -81,6 +87,12 @@ export interface ResolvedCliConfig { readonly origins: ReadonlyMap; /** Values `tolerateInvalid` left out; always empty otherwise. */ readonly invalid: ReadonlyArray; + /** Remotes whose `project_id` is duplicated or malformed; a `validation` load reports them here instead of failing, and they are empty otherwise. */ + readonly remoteFailures: ReadonlyArray; + /** Schema decode failures a `validation` load pruned past, in order; empty otherwise. */ + readonly schemaFailures: ReadonlyArray; + /** The key paths a `validation` load deleted from the document so decoding could continue; empty otherwise. */ + readonly pruned: ReadonlyArray>; /** The entry names of a family: those the registry declares plus those in the merged document. */ readonly familyNames: (family: CliConfigFamilyId) => ReadonlyArray; /** Whether the merged document holds a value or table at the dotted path. */ diff --git a/apps/cli/src/config/cli-config.errors.ts b/apps/cli/src/config/cli-config.errors.ts index a1c94392ec..ba34a41def 100644 --- a/apps/cli/src/config/cli-config.errors.ts +++ b/apps/cli/src/config/cli-config.errors.ts @@ -18,6 +18,8 @@ export class CliConfigValueError extends Data.TaggedError("CliConfigValueError") readonly source?: string; readonly envName?: string; readonly flag?: string; + /** What a valid value is, as in "expected …"; absent for failures that are not a codec rejection. */ + readonly expected?: string; /** Per-entry decode failures, merged when several keys fail in one load. */ readonly issues?: ReadonlyArray; }> { diff --git a/apps/cli/src/shared/config/cli-config-load.errors.ts b/apps/cli/src/shared/config/cli-config-load.errors.ts index 1e6f5939b1..31c5a8bd9d 100644 --- a/apps/cli/src/shared/config/cli-config-load.errors.ts +++ b/apps/cli/src/shared/config/cli-config-load.errors.ts @@ -9,6 +9,8 @@ import { /** The config document, project env files, or remote selection could not be loaded. */ export class CliConfigLoadError extends Data.TaggedError("CliConfigLoadError")<{ readonly message: string; + /** The `[remotes.]` block the failure is about, when it is about one. */ + readonly remote?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index d846e5eb6f..ffe2d4742a 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -218,7 +218,9 @@ describe("project init templates", () => { expect(uncommented).toContain("[auth.passkey]"); expect(uncommented).toContain("[auth.webauthn]"); expect( - findUnknownCliConfigKeys(SmolToml.parse(uncommented)).filter((key) => !isDocumentOnly(key)), + findUnknownCliConfigKeys(SmolToml.parse(uncommented)) + .map(({ path }) => path) + .filter((key) => !isDocumentOnly(key)), ).toEqual([]); } }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 6e1d61ac18..5fd8394b4e 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -110,6 +110,9 @@ ConfigPushSslEnforcementUpdateStatusError ConfigPushStorageUpdateNetworkError ConfigPushStorageUpdateStatusError ConfigPushWorkdirError +ConfigValidateLoadError +ConfigValidateOutputFlagUnsupportedError +ConfigValidateWorkdirError ConflictingFunctionDeployFlagsError ConflictingFunctionDownloadFlagsError ContainerCreateError diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index ae9425aab5..fa2b5793d5 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -39,7 +39,7 @@ artifacts (`./schema.json`, `./project-schema.json`). otherwise-internal registry data (`AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, `ProjectConfigMappingRow`, `ProjectConfigApiAttributes`, `ENV_CAPTURE_REGEX`, and `withoutSafeIntegerBounds` for the CLI's published lock schema), the - JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, and `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document). It also carries + JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `locateCliConfigKey`/`CliConfigLocation` (line/column of a key or value in raw TOML/JSON/JSONC text), `cliConfigLineKeys` (the key path each single-entry line holds) and `isSecretPath`. It also carries `supabase config pull`/`diff`'s own support surface (CLI-2064): the format-preserving surgical editor (`applyConfigEdits` and its `ConfigEdit`/`ConfigEditOutcome`/`ConfigEditRefusal`/`ConfigEditRefusalReason`/`ConfigEditValue`/ diff --git a/packages/config/src/config-edit.ts b/packages/config/src/config-edit.ts index 66a3e1da7c..ea68a79cfe 100644 --- a/packages/config/src/config-edit.ts +++ b/packages/config/src/config-edit.ts @@ -310,7 +310,7 @@ function renderLeafValue(value: ConfigEditLeafValue): string { // comments, table headers, and key-value lines (multi-line arrays/strings scanned as single // opaque spans). Never throws — an unrecognized construct yields an `{ error }` result instead. -interface TomlHeaderToken { +export interface TomlHeaderToken { readonly kind: "header"; readonly start: number; readonly end: number; @@ -318,7 +318,7 @@ interface TomlHeaderToken { readonly isArrayOfTables: boolean; } -interface TomlKeyToken { +export interface TomlKeyToken { readonly kind: "kv"; readonly start: number; readonly end: number; @@ -345,7 +345,7 @@ interface TomlCommentToken { type TomlToken = TomlHeaderToken | TomlKeyToken | TomlBlankToken | TomlCommentToken; -interface TomlScanResult { +export interface TomlScanResult { readonly source: string; readonly tokens: ReadonlyArray; readonly headers: ReadonlyArray; @@ -353,7 +353,7 @@ interface TomlScanResult { readonly endsWithNewline: boolean; } -interface TomlScanError { +export interface TomlScanError { readonly error: string; } @@ -653,7 +653,7 @@ function scanDottedKeyPath(source: string, pos: number): DottedKeyScan | null { return { segments, end: cursor }; } -function scanTomlDocument(source: string): TomlScanResult | TomlScanError { +export function scanTomlDocument(source: string): TomlScanResult | TomlScanError { const tokens: Array = []; const headers: Array = []; const length = source.length; diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts new file mode 100644 index 0000000000..b4fbb7b512 --- /dev/null +++ b/packages/config/src/config-locate.ts @@ -0,0 +1,380 @@ +import { parseTree, type Node, type ParseError } from "jsonc-parser"; +import { + scanTomlDocument, + type TomlHeaderToken, + type TomlKeyToken, + type TomlScanError, + type TomlScanResult, +} from "./config-edit.ts"; +import type { ConfigFormat } from "./config-format.ts"; + +/** + * A 1-based source span; `endLine` and `endColumn` (exclusive) are absent for an empty span, and + * `exact` is false when the location is the nearest enclosing ancestor. + */ +export interface CliConfigLocation { + readonly line: number; + readonly column: number; + readonly endLine?: number; + readonly endColumn?: number; + readonly exact: boolean; +} + +interface Span { + readonly start: number; + readonly end: number; +} + +function lineStarts(text: string): ReadonlyArray { + const starts = [0]; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === "\n") starts.push(index + 1); + } + return starts; +} + +function lineIndexOf(starts: ReadonlyArray, offset: number): number { + let low = 0; + let high = starts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ((starts[middle] ?? 0) <= offset) low = middle; + else high = middle - 1; + } + return low; +} + +function toLocation(starts: ReadonlyArray, span: Span, exact: boolean): CliConfigLocation { + const first = lineIndexOf(starts, span.start); + const last = lineIndexOf(starts, Math.max(span.start, span.end - 1)); + const column = span.start - (starts[first] ?? 0) + 1; + const end = + span.end > span.start + ? { endLine: last + 1, endColumn: span.end - (starts[last] ?? 0) + 1 } + : {}; + return { line: first + 1, column, ...end, exact }; +} + +function isScanError(result: TomlScanResult | TomlScanError): result is TomlScanError { + return "error" in result; +} + +const isInlineWhitespace = (character: string | undefined) => + character === " " || character === "\t"; + +function skipInlineWhitespace(source: string, position: number): number { + let cursor = position; + while (isInlineWhitespace(source[cursor])) cursor += 1; + return cursor; +} + +function trimEnd(source: string, end: number): number { + let cursor = end; + while (cursor > 0 && isInlineWhitespace(source[cursor - 1])) cursor -= 1; + return cursor; +} + +function tomlKeySpan(source: string, token: TomlKeyToken): Span { + const start = skipInlineWhitespace(source, token.start); + const equals = source.lastIndexOf("=", token.valueStart - 1); + return { start, end: trimEnd(source, equals) }; +} + +function tomlHeaderSpan(source: string, token: TomlHeaderToken): Span { + const open = skipInlineWhitespace(source, token.start); + const start = skipInlineWhitespace(source, open + (token.isArrayOfTables ? 2 : 1)); + const lineEnd = source.indexOf("\n", start); + const line = source.slice(start, lineEnd === -1 ? source.length : lineEnd); + const close = line.indexOf("]"); + return { start, end: trimEnd(source, close === -1 ? start + line.length : start + close) }; +} + +function inlineValueEnd(source: string, start: number, limit: number): number { + let depth = 0; + let quote: string | undefined; + for (let index = start; index < limit; index += 1) { + const character = source[index]; + if (quote !== undefined) { + if (character === "\\" && quote === '"') index += 1; + else if (character === quote) quote = undefined; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === "{" || character === "[") { + depth += 1; + } else if (character === "}" || character === "]") { + if (depth === 0) return trimEnd(source, index); + depth -= 1; + } else if (character === "," && depth === 0) { + return trimEnd(source, index); + } + } + return trimEnd(source, limit); +} + +interface InlineEntry { + readonly keys: ReadonlyArray; + readonly key: Span; + readonly value: Span; +} + +const isBareKeyCharacter = (character: string | undefined) => + character !== undefined && /[A-Za-z0-9_-]/.test(character); + +function scanKeyPart( + source: string, + start: number, + limit: number, +): { readonly text: string; readonly end: number } | undefined { + const opener = source[start]; + if (opener === '"' || opener === "'") { + let cursor = start + 1; + while (cursor < limit && source[cursor] !== opener) { + cursor += opener === '"' && source[cursor] === "\\" ? 2 : 1; + } + return cursor >= limit ? undefined : { text: source.slice(start + 1, cursor), end: cursor + 1 }; + } + let cursor = start; + while (cursor < limit && isBareKeyCharacter(source[cursor])) cursor += 1; + return cursor === start ? undefined : { text: source.slice(start, cursor), end: cursor }; +} + +function inlineTableEntries(source: string, table: Span): ReadonlyArray | undefined { + if (source[table.start] !== "{") return undefined; + const limit = table.end - 1; + const entries: Array = []; + let cursor = skipInlineWhitespace(source, table.start + 1); + while (cursor < limit) { + const keys: Array = []; + const keyStart = cursor; + let keyEnd = cursor; + for (;;) { + const part = scanKeyPart(source, cursor, limit); + if (part === undefined) return undefined; + keys.push(part.text); + keyEnd = part.end; + cursor = skipInlineWhitespace(source, part.end); + if (source[cursor] !== ".") break; + cursor = skipInlineWhitespace(source, cursor + 1); + } + if (source[cursor] !== "=") return undefined; + const valueStart = skipInlineWhitespace(source, cursor + 1); + const valueEnd = inlineValueEnd(source, valueStart, limit); + entries.push({ + keys, + key: { start: keyStart, end: keyEnd }, + value: { start: valueStart, end: valueEnd }, + }); + cursor = skipInlineWhitespace(source, valueEnd); + if (source[cursor] === ",") cursor = skipInlineWhitespace(source, cursor + 1); + } + return entries; +} + +function locateInInlineTable( + source: string, + token: TomlKeyToken, + rest: ReadonlyArray, + target: "key" | "value", +): Span | undefined { + let table: Span = { start: token.valueStart, end: token.valueEnd }; + let remaining = rest; + while (remaining.length > 0) { + const entry = inlineTableEntries(source, table)?.find( + (candidate) => + candidate.keys.length <= remaining.length && + candidate.keys.every((key, index) => key === remaining[index]), + ); + if (entry === undefined) return undefined; + remaining = remaining.slice(entry.keys.length); + if (remaining.length === 0) return target === "key" ? entry.key : entry.value; + table = entry.value; + } + return undefined; +} + +function startsWith(path: ReadonlyArray, prefix: ReadonlyArray): boolean { + return prefix.every((segment, index) => path[index] === segment); +} + +function equals(path: ReadonlyArray, other: ReadonlyArray): boolean { + return path.length === other.length && startsWith(path, other); +} + +interface ScannedToml { + readonly source: string; + readonly scan: TomlScanResult | TomlScanError; + readonly starts: ReadonlyArray; +} + +let lastScanned: ScannedToml | undefined; + +function scanOnce(source: string): ScannedToml { + if (lastScanned?.source === source) return lastScanned; + lastScanned = { source, scan: scanTomlDocument(source), starts: lineStarts(source) }; + return lastScanned; +} + +function locateToml( + text: string, + path: ReadonlyArray, + target: "key" | "value", +): CliConfigLocation | undefined { + const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + const { scan, starts } = scanOnce(source); + if (isScanError(scan)) return undefined; + const keys = scan.tokens.filter((token): token is TomlKeyToken => token.kind === "kv"); + const spanOf = (token: TomlKeyToken) => + target === "value" + ? { start: token.valueStart, end: token.valueEnd } + : tomlKeySpan(source, token); + + for (let length = path.length; length > 0; length -= 1) { + const wanted = path.slice(0, length); + const exact = length === path.length; + const key = keys.find((token) => equals(token.path, wanted)); + if (key !== undefined) return toLocation(starts, spanOf(key), exact); + const header = scan.headers.find((token) => equals(token.path, wanted)); + if (header !== undefined) return toLocation(starts, tomlHeaderSpan(source, header), exact); + + const inline = keys.find( + (token) => + token.insideInlineTable && token.path.length < length && startsWith(wanted, token.path), + ); + if (inline !== undefined) { + const span = locateInInlineTable(source, inline, wanted.slice(inline.path.length), target); + if (span !== undefined) return toLocation(starts, span, exact); + } + + const descendant = scan.tokens.find( + (token): token is TomlKeyToken | TomlHeaderToken => + (token.kind === "kv" || token.kind === "header") && startsWith(token.path, wanted), + ); + if (descendant !== undefined) { + const span = + descendant.kind === "kv" + ? tomlKeySpan(source, descendant) + : tomlHeaderSpan(source, descendant); + return toLocation(starts, span, false); + } + } + return undefined; +} + +function childOf(node: Node, segment: string): { entry: Node; value: Node } | undefined { + if (node.type === "array") { + const value = node.children?.[Number(segment)]; + return value === undefined ? undefined : { entry: value, value }; + } + if (node.type !== "object") return undefined; + const entry = node.children?.find((property) => property.children?.[0]?.value === segment); + const value = entry?.children?.[1]; + return entry === undefined || value === undefined ? undefined : { entry, value }; +} + +function locateJson( + text: string, + path: ReadonlyArray, + target: "key" | "value", +): CliConfigLocation | undefined { + const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + const root = parseTree(source, [], { allowTrailingComma: true, disallowComments: false }); + if (root === undefined) return undefined; + const starts = lineStarts(source); + + for (let length = path.length; length > 0; length -= 1) { + let node: Node | undefined = root; + let entry: Node | undefined; + for (const segment of path.slice(0, length)) { + const child: { entry: Node; value: Node } | undefined = + node === undefined ? undefined : childOf(node, segment); + node = child?.value; + entry = child?.entry; + } + if (node === undefined || entry === undefined) continue; + const picked = target === "key" ? (entry.children?.[0] ?? node) : node; + return toLocation( + starts, + { start: picked.offset, end: picked.offset + picked.length }, + length === path.length, + ); + } + return undefined; +} + +/** + * Finds where `path` sits in raw config text, falling back to the nearest enclosing ancestor + * (`exact: false`) when the key itself is absent; undefined when the text cannot be scanned. + */ +export function locateCliConfigKey( + source: string, + format: ConfigFormat, + path: ReadonlyArray, + target: "key" | "value", +): CliConfigLocation | undefined { + return format === "toml" ? locateToml(source, path, target) : locateJson(source, path, target); +} + +function tomlLineKeys(source: string): ReadonlyMap> { + const { scan, starts } = scanOnce(source); + const found = new Map>(); + if (isScanError(scan)) return found; + for (const token of scan.tokens) { + if (token.kind !== "kv" || token.insideInlineTable) continue; + const line = lineIndexOf(starts, token.start); + if (lineIndexOf(starts, Math.max(token.valueStart, token.valueEnd - 1)) !== line) continue; + found.set(line + 1, token.path); + } + return found; +} + +function jsonLineKeys(source: string): ReadonlyMap> { + const errors: Array = []; + const root = parseTree(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + const found = new Map>(); + if (root === undefined || errors.length > 0) return found; + const starts = lineStarts(source); + const keysOnLine = new Map(); + const candidates = new Map>(); + const isFlat = (node: Node) => + node.type !== "object" && + (node.type !== "array" || + (node.children ?? []).every((child) => child.type !== "object" && child.type !== "array")); + const walk = (node: Node, path: ReadonlyArray): void => { + if (node.type === "array") { + node.children?.forEach((child, index) => walk(child, [...path, String(index)])); + return; + } + if (node.type !== "object") return; + for (const property of node.children ?? []) { + const [key, value] = property.children ?? []; + if (key === undefined || value === undefined || typeof key.value !== "string") continue; + const line = lineIndexOf(starts, property.offset); + keysOnLine.set(line, (keysOnLine.get(line) ?? 0) + 1); + const childPath = [...path, key.value]; + const sameLine = + lineIndexOf(starts, Math.max(value.offset, value.offset + value.length - 1)) === line; + if (isFlat(value) && sameLine) candidates.set(line, childPath); + else walk(value, childPath); + } + }; + walk(root, []); + for (const [line, path] of candidates) { + if (keysOnLine.get(line) === 1) found.set(line + 1, path); + } + return found; +} + +/** + * The key path of every 1-based line that holds one complete key and its scalar or flat-array + * value and nothing else; empty when the text cannot be parsed. + */ +export function cliConfigLineKeys( + text: string, + format: ConfigFormat, +): ReadonlyMap> { + const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + return format === "toml" ? tomlLineKeys(source) : jsonLineKeys(source); +} diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts new file mode 100644 index 0000000000..b819db5ec1 --- /dev/null +++ b/packages/config/src/config-locate.unit.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "vitest"; +import { cliConfigLineKeys, locateCliConfigKey } from "./config-locate.ts"; +import { isSecretPath } from "./lib/secret-paths.ts"; + +const toml = [ + "project_id = 'demo'", + "", + "[api]", + "port = 54321", + "tls.enabled = true", + "", + "[auth.external.github]", + "enabled = true", + "", + "[db]", + "settings = { max_connections = 10, nested = { deep = 1 } }", + "", + "[remotes.staging.db]", + "port = 1", +].join("\n"); + +describe("locateCliConfigKey (toml)", () => { + test("locates a key under a header", () => { + expect(locateCliConfigKey(toml, "toml", ["api", "port"], "key")).toEqual({ + line: 4, + column: 1, + endLine: 4, + endColumn: 5, + exact: true, + }); + }); + + test("locates a value span", () => { + expect(locateCliConfigKey(toml, "toml", ["api", "port"], "value")).toEqual({ + line: 4, + column: 8, + endLine: 4, + endColumn: 13, + exact: true, + }); + }); + + test("locates a dotted key", () => { + expect(locateCliConfigKey(toml, "toml", ["api", "tls", "enabled"], "key")).toMatchObject({ + line: 5, + column: 1, + endColumn: 12, + exact: true, + }); + }); + + test("locates keys inside an inline table", () => { + const key = locateCliConfigKey(toml, "toml", ["db", "settings", "max_connections"], "key"); + expect(key).toMatchObject({ line: 11, exact: true }); + const line = toml.split("\n")[10] ?? ""; + expect(line.slice((key?.column ?? 0) - 1, (key?.endColumn ?? 0) - 1)).toBe("max_connections"); + const deep = locateCliConfigKey(toml, "toml", ["db", "settings", "nested", "deep"], "value"); + expect(line.slice((deep?.column ?? 0) - 1, (deep?.endColumn ?? 0) - 1)).toBe("1"); + }); + + test("locates a header-only path", () => { + const location = locateCliConfigKey(toml, "toml", ["auth", "external", "github"], "key"); + expect(location).toMatchObject({ line: 7, column: 2, exact: true }); + expect(location?.endColumn).toBe(2 + "auth.external.github".length); + }); + + test("falls back to the parent header for a missing leaf", () => { + expect( + locateCliConfigKey(toml, "toml", ["auth", "external", "github", "secret"], "key"), + ).toMatchObject({ + line: 7, + exact: false, + }); + }); + + test("falls back to the first descendant for an implicit table", () => { + expect(locateCliConfigKey(toml, "toml", ["remotes", "staging"], "key")).toMatchObject({ + line: 13, + exact: false, + }); + }); + + test("locates a remote path", () => { + expect( + locateCliConfigKey(toml, "toml", ["remotes", "staging", "db", "port"], "key"), + ).toMatchObject({ + line: 14, + column: 1, + exact: true, + }); + }); + + test("returns undefined when the document cannot be scanned", () => { + expect(locateCliConfigKey("[api\nport = 1", "toml", ["api", "port"], "key")).toBeUndefined(); + }); + + test("returns undefined when nothing matches", () => { + expect(locateCliConfigKey(toml, "toml", ["storage", "x"], "key")).toBeUndefined(); + }); +}); + +describe("locateCliConfigKey (jsonc)", () => { + const jsonc = [ + "// comment", + "{", + ' "api": {', + ' "port": 54321, /* inline */', + ' "list": [1, 2,],', + " },", + "}", + ].join("\n"); + + test("locates a key and a value past comments, trailing commas and a BOM", () => { + expect(locateCliConfigKey(jsonc, "json", ["api", "port"], "key")).toEqual({ + line: 4, + column: 5, + endLine: 4, + endColumn: 11, + exact: true, + }); + expect(locateCliConfigKey(jsonc, "json", ["api", "port"], "value")).toEqual({ + line: 4, + column: 13, + endLine: 4, + endColumn: 18, + exact: true, + }); + }); + + test("resolves array indices", () => { + expect(locateCliConfigKey(jsonc, "json", ["api", "list", "1"], "value")).toMatchObject({ + line: 5, + column: 17, + exact: true, + }); + }); + + test("falls back to the nearest existing ancestor", () => { + expect(locateCliConfigKey(jsonc, "json", ["api", "missing"], "key")).toMatchObject({ + line: 3, + column: 3, + exact: false, + }); + }); +}); + +describe("isSecretPath", () => { + test.each([ + ["db", "vault", "anything"], + ["edge_runtime", "secrets", "KEY"], + ["auth", "external", "github", "secret"], + ["auth", "hook", "send_sms", "secrets"], + ])("treats %s as secret", (...path) => { + expect(isSecretPath(path)).toBe(true); + }); + + test("does not treat a plain key as secret", () => { + expect(isSecretPath(["api", "port"])).toBe(false); + }); +}); + +describe("locateCliConfigKey (spans and inline tables)", () => { + test("a multi-line value reports its end line", () => { + const source = ["[auth.email.smtp]", 'pass = """', "secret", '"""', 'host = "h"'].join("\n"); + expect( + locateCliConfigKey(source, "toml", ["auth", "email", "smtp", "pass"], "value"), + ).toMatchObject({ + line: 2, + endLine: 4, + }); + }); + + test("a BOM-prefixed toml document locates the first header", () => { + const source = "\uFEFF[api]\nport = 1\n"; + expect(locateCliConfigKey(source, "toml", ["api", "port"], "key")).toMatchObject({ + line: 2, + column: 1, + exact: true, + }); + }); + + test("an inline table ignores key-like text inside strings and prefers the outer key", () => { + const source = 'api = { note = "port = 1", port = 2 }\n'; + const location = locateCliConfigKey(source, "toml", ["api", "port"], "value"); + expect(source.slice((location?.column ?? 0) - 1, (location?.endColumn ?? 0) - 1)).toBe("2"); + }); +}); + +describe("cliConfigLineKeys", () => { + test("maps each single-line toml key to its full path, skipping inline tables", () => { + const keys = cliConfigLineKeys(toml, "toml"); + expect(keys.get(4)).toEqual(["api", "port"]); + expect(keys.get(5)).toEqual(["api", "tls", "enabled"]); + expect(keys.get(14)).toEqual(["remotes", "staging", "db", "port"]); + expect(keys.has(3)).toBe(false); + expect(keys.has(11)).toBe(false); + }); + + test("leaves out a toml value that spans lines", () => { + const keys = cliConfigLineKeys( + '[auth]\nsite_url = "x"\nadditional_redirect_urls = [\n "a",\n]\n', + "toml", + ); + expect(keys.get(2)).toEqual(["auth", "site_url"]); + expect(keys.has(3)).toBe(false); + }); + + test("maps json lines holding exactly one flat property", () => { + const source = ["{", ' "api": {', ' "port": 1,', ' "a": 1, "b": 2', " }", "}"].join( + "\n", + ); + const keys = cliConfigLineKeys(source, "json"); + expect(keys.get(3)).toEqual(["api", "port"]); + expect(keys.has(2)).toBe(false); + expect(keys.has(4)).toBe(false); + }); + + test("is empty when the text does not parse", () => { + expect(cliConfigLineKeys("[api\nport = 1", "toml").size).toBe(0); + expect(cliConfigLineKeys('{ "api": { "port": 1 ', "json").size).toBe(0); + }); +}); diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 1130df97b1..d06c1179cc 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -527,11 +527,14 @@ describe("src/internal.ts export surface", () => { "PROVIDER_SIGNUP_ALIASES", "applyConfigEdits", "cliConfigFormatForPath", + "cliConfigLineKeys", "decodeCliConfigDocumentForValidationEffect", "decodeMergedCliConfig", "dualScopeProjectConfigPaths", "findUnknownCliConfigKeys", + "isSecretPath", "loadCliConfig", + "locateCliConfigKey", "mapMigrationCheckRules", "mergeParsedCliConfig", "parseCliConfigDocument", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 73485b0f49..560b2d32d8 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -52,4 +52,6 @@ export { export { JsoncSyntaxError, parseJsonc } from "./jsonc.ts"; export { PROVIDER_SIGNUP_ALIASES } from "./lib/provider-signup.ts"; export { CliConfigWriteError } from "./errors.ts"; -export { findUnknownCliConfigKeys } from "./unknown-keys.ts"; +export { findUnknownCliConfigKeys, type UnknownCliConfigKey } from "./unknown-keys.ts"; +export { isSecretPath } from "./lib/secret-paths.ts"; +export { cliConfigLineKeys, locateCliConfigKey, type CliConfigLocation } from "./config-locate.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 59e263b4f4..185e370853 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -15,6 +15,7 @@ import { import { updateJsonDocument } from "./config-edit.ts"; import type { ConfigFormat } from "./config-format.ts"; import { + type CliConfigRemovedKeyError, CliConfigFileConflictError, DuplicateRemoteProjectIdError, InvalidRemoteProjectIdError, @@ -26,7 +27,7 @@ import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts"; import { normalizeDeprecatedProviderSignupKeys } from "./lib/provider-signup.ts"; import { parseJsonc } from "./jsonc.ts"; import { findCliProjectPaths } from "./paths.ts"; -import { rejectRemovedConfigKeys } from "./removed-config-keys.ts"; +import { rejectRemovedConfigKeys, stripRemovedConfigKeys } from "./removed-config-keys.ts"; import { SUPPORTED_CLI_CONFIG_SCHEMA_VERSION } from "./schema-metadata.ts"; import { readCliConfigSchemaDeclaration, @@ -282,8 +283,9 @@ function parseCliConfig( format: ConfigFormat, path: string, appliedRemote: string | undefined, + allIssues?: true, ): Effect.Effect { - return validateCliConfig(document).pipe( + return validateCliConfig(document, allIssues === true ? { allIssues } : undefined).pipe( Effect.mapError( (cause) => new CliConfigParseError({ @@ -426,15 +428,30 @@ const readCliConfigDocumentFile = Effect.fnUntraced(function* (filePath: string) return { format, content, document, schemaDeclaration } as const; }); -const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* (filePath: string) { +const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* ( + filePath: string, + tolerateRemovedKeys = false, +) { const { format, content, document, schemaDeclaration } = yield* readCliConfigDocumentFile(filePath); - const withoutRemoved = isObject(document) - ? yield* rejectRemovedConfigKeys(document, filePath, format) - : document; + const removal = + isObject(document) && tolerateRemovedKeys ? stripRemovedConfigKeys(document) : undefined; + const withoutRemoved = removal + ? removal.document + : isObject(document) + ? yield* rejectRemovedConfigKeys(document, filePath, format) + : document; const { document: normalized, deprecatedPaths } = normalizeDeprecatedProviderSignupKeys(withoutRemoved); - return { format, content, document, schemaDeclaration, normalized, deprecatedPaths } as const; + return { + format, + content, + document, + schemaDeclaration, + normalized, + deprecatedPaths, + removedKeys: removal?.removedKeys ?? [], + } as const; }); /** @@ -510,6 +527,8 @@ export interface DecodeMergedCliConfigOptions { readonly document?: Record; /** Paths whose values are already resolved: the decode keeps them verbatim instead of expanding `env()` again. */ readonly resolvedPaths?: ReadonlyArray>; + /** Report every schema issue instead of stopping at the first. */ + readonly allIssues?: true; } export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(function* ( @@ -563,6 +582,7 @@ export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(functio merged.format, merged.path, merged.appliedRemote, + options.allIssues, ); const localPathKeys = new Set(collectLeafPaths(merged.rawDocument).map(pathKey)); @@ -735,6 +755,8 @@ export interface ParsedCliConfigDocument { readonly deprecatedPaths: ReadonlyArray; readonly rawDocument: Record | undefined; readonly normalized: unknown; + /** The removed keys deleted from `normalized` under `tolerateRemovedKeys`; empty otherwise. */ + readonly removedKeys: ReadonlyArray; } /** @@ -747,6 +769,8 @@ export const parseCliConfigDocumentFile = Effect.fn("CliConfig.parseDocument")(f options?: { readonly search?: boolean; readonly tomlOnly?: boolean; + /** Deletes removed keys and reports them in `removedKeys` instead of failing. */ + readonly tolerateRemovedKeys?: boolean; }, ) { const located = yield* locateCliConfigFile(cwd, options); @@ -755,8 +779,8 @@ export const parseCliConfigDocumentFile = Effect.fn("CliConfig.parseDocument")(f return null; } - const { format, content, document, schemaDeclaration, normalized, deprecatedPaths } = - yield* readAndNormalizeCliConfigFile(located.filePath); + const { format, content, document, schemaDeclaration, normalized, deprecatedPaths, removedKeys } = + yield* readAndNormalizeCliConfigFile(located.filePath, options?.tolerateRemovedKeys); return { path: located.filePath, @@ -768,6 +792,7 @@ export const parseCliConfigDocumentFile = Effect.fn("CliConfig.parseDocument")(f deprecatedPaths, rawDocument: isObject(normalized) ? normalized : undefined, normalized, + removedKeys, } satisfies ParsedCliConfigDocument; }); diff --git a/packages/config/src/removed-config-keys.ts b/packages/config/src/removed-config-keys.ts index 65fa008e79..4d42f9db8e 100644 --- a/packages/config/src/removed-config-keys.ts +++ b/packages/config/src/removed-config-keys.ts @@ -10,6 +10,7 @@ const REMOVED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; function scanScope( scope: Record, prefix: string, + dropRemoved: boolean, ): { readonly removedKeys: ReadonlyArray; readonly scope: Record; @@ -19,6 +20,10 @@ function scanScope( if (Object.hasOwn(scope, "inbucket")) { removedKeys.push({ key: `[${prefix}inbucket]`, replacement: `[${prefix}local_smtp]` }); + if (dropRemoved) { + stripped = { ...stripped }; + delete stripped["inbucket"]; + } } const experimental = scope["experimental"]; @@ -26,12 +31,17 @@ function scanScope( if (experimental["orioledb_version"] === "") { const withoutEmptyVersion = { ...experimental }; delete withoutEmptyVersion["orioledb_version"]; - stripped = { ...scope, experimental: withoutEmptyVersion }; + stripped = { ...stripped, experimental: withoutEmptyVersion }; } else { removedKeys.push({ key: `${prefix}experimental.orioledb_version`, replacement: `${prefix}db.orioledb_version`, }); + if (dropRemoved) { + const withoutVersion = { ...experimental }; + delete withoutVersion["orioledb_version"]; + stripped = { ...stripped, experimental: withoutVersion }; + } } } @@ -44,6 +54,18 @@ function scanScope( key: `[${prefix}auth.external.${provider}]`, replacement: `[${prefix}auth.external.${provider}_oidc]`, }); + if (dropRemoved) { + const strippedAuth = stripped["auth"]; + const strippedExternal = isObject(strippedAuth) ? strippedAuth["external"] : undefined; + if (isObject(strippedAuth) && isObject(strippedExternal)) { + const withoutProvider = { ...strippedExternal }; + delete withoutProvider[provider]; + stripped = { + ...stripped, + auth: { ...strippedAuth, external: withoutProvider }, + }; + } + } } } } @@ -56,11 +78,14 @@ function scanScope( * returned document drops an empty `experimental.orioledb_version`, which older `supabase init` * templates wrote and which carries no meaning. */ -function detectRemovedConfigKeys(document: Record): { +function detectRemovedConfigKeys( + document: Record, + dropRemoved: boolean, +): { readonly removedKeys: ReadonlyArray; readonly document: Record; } { - const root = scanScope(document, ""); + const root = scanScope(document, "", dropRemoved); const removedKeys = [...root.removedKeys]; const remotes = document["remotes"]; if (!isObject(remotes)) { @@ -71,7 +96,7 @@ function detectRemovedConfigKeys(document: Record): { if (!isObject(remote)) { return [name, remote]; } - const scanned = scanScope(remote, `remotes.${name}.`); + const scanned = scanScope(remote, `remotes.${name}.`, dropRemoved); removedKeys.push(...scanned.removedKeys); return [name, scanned.scope]; }), @@ -85,8 +110,16 @@ export const rejectRemovedConfigKeys = ( path: string, format: ConfigFormat, ): Effect.Effect, CliConfigRemovedKeyError> => { - const { removedKeys, document: stripped } = detectRemovedConfigKeys(document); + const { removedKeys, document: stripped } = detectRemovedConfigKeys(document, false); return removedKeys.length === 0 ? Effect.succeed(stripped) : Effect.fail(new CliConfigRemovedKeyError({ path, format, removedKeys })); }; + +/** Every removed key the raw document uses, and the document with each of them deleted. */ +export const stripRemovedConfigKeys = ( + document: Record, +): { + readonly removedKeys: ReadonlyArray; + readonly document: Record; +} => detectRemovedConfigKeys(document, true); diff --git a/packages/config/src/unknown-keys.ts b/packages/config/src/unknown-keys.ts index 40b98d4ea7..eeeb613178 100644 --- a/packages/config/src/unknown-keys.ts +++ b/packages/config/src/unknown-keys.ts @@ -11,44 +11,60 @@ type KeyPath = ReadonlyArray; const SEPARATOR = "\u0000"; const encodePath = (path: KeyPath): string => path.join(SEPARATOR); +type KnownKeys = ReadonlyArray; +type Found = Map; + /** A union reports a key only when every member that rejected the value flagged it. */ -const intersect = (sets: ReadonlyArray>): Set => { - const [first, ...rest] = sets; - return new Set([...(first ?? [])].filter((key) => rest.every((set) => set.has(key)))); +const intersect = (founds: ReadonlyArray): Found => { + const [first, ...rest] = founds; + return new Map([...(first ?? [])].filter(([key]) => rest.every((found) => found.has(key)))); }; -const collect = (issue: SchemaIssue.Issue, prefix: KeyPath): Set => { +const collect = (issue: SchemaIssue.Issue, prefix: KeyPath, known: KnownKeys): Found => { switch (issue._tag) { case "Pointer": - return collect(issue.issue, [...prefix, ...issue.path.map(String)]); - case "Composite": - return new Set(issue.issues.flatMap((child) => [...collect(child, prefix)])); + return collect(issue.issue, [...prefix, ...issue.path.map(String)], known); + case "Composite": { + const siblings = + issue.ast._tag === "Objects" + ? issue.ast.propertySignatures.map((signature) => String(signature.name)) + : []; + return new Map(issue.issues.flatMap((child) => [...collect(child, prefix, siblings)])); + } case "AnyOf": - return intersect(issue.issues.map((child) => collect(child, prefix))); + return intersect(issue.issues.map((child) => collect(child, prefix, known))); case "Encoding": case "Filter": - return collect(issue.issue, prefix); + return collect(issue.issue, prefix, known); case "UnexpectedKey": - return new Set([encodePath(prefix)]); + return new Map([[encodePath(prefix), known]]); default: - return new Set(); + return new Map(); } }; -const unknownKeysOf = (exit: Exit.Exit, prefix: KeyPath) => { - if (Exit.isSuccess(exit)) return new Set(); +const unknownKeysOf = (exit: Exit.Exit, prefix: KeyPath): Found => { + if (Exit.isSuccess(exit)) return new Map(); const error = Cause.findErrorOption(exit.cause); - return Option.isSome(error) ? collect(error.value.issue, prefix) : new Set(); + return Option.isSome(error) ? collect(error.value.issue, prefix, []) : new Map(); }; +/** An unknown key's path plus the keys its parent table accepts. */ +export interface UnknownCliConfigKey { + readonly path: KeyPath; + readonly knownKeys: ReadonlyArray; +} + /** - * Key paths in a raw config document that `CliConfigSchema` does not accept, remote blocks under + * Unknown keys in a raw config document that `CliConfigSchema` does not accept, remote blocks under * `["remotes", , ...]`. Pass a document already normalized for deprecated keys. */ -export function findUnknownCliConfigKeys(document: unknown): ReadonlyArray { +export function findUnknownCliConfigKeys(document: unknown): ReadonlyArray { if (!isObject(document)) return []; const { [cliConfigSchemaKey]: _schemaKey, remotes, ...base } = document; const keys = unknownKeysOf(decodeCliConfig({ ...base, remotes: {} }), []); const remoteKeys = unknownKeysOf(decodeRemotes(remotes ?? {}), ["remotes"]); - return [...keys, ...remoteKeys].sort().map((encoded) => encoded.split(SEPARATOR)); + return [...keys, ...remoteKeys] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([encoded, knownKeys]) => ({ path: encoded.split(SEPARATOR), knownKeys })); } diff --git a/packages/config/src/unknown-keys.unit.test.ts b/packages/config/src/unknown-keys.unit.test.ts index 4bb368d456..7df72a825f 100644 --- a/packages/config/src/unknown-keys.unit.test.ts +++ b/packages/config/src/unknown-keys.unit.test.ts @@ -47,7 +47,7 @@ describe("findUnknownCliConfigKeys", () => { ]; test.each(unknown)("reports $name", ({ document, expected }) => { - expect(findUnknownCliConfigKeys(document)).toEqual(expected); + expect(findUnknownCliConfigKeys(document).map(({ path }) => path)).toEqual(expected); }); const clean: ReadonlyArray<{ readonly name: string; readonly document: unknown }> = [ @@ -76,4 +76,17 @@ describe("findUnknownCliConfigKeys", () => { test.each(clean)("reports nothing for $name", ({ document }) => { expect(findUnknownCliConfigKeys(document)).toEqual([]); }); + + test("reports the sibling keys of the table holding an unknown key", () => { + const [found] = findUnknownCliConfigKeys({ api: { prot: 1 } }); + expect(found?.path).toEqual(["api", "prot"]); + expect(found?.knownKeys).toContain("port"); + expect(found?.knownKeys).not.toContain("prot"); + }); + + test("reports the sibling keys of a remote table", () => { + const [found] = findUnknownCliConfigKeys({ remotes: { staging: { api: { prot: 1 } } } }); + expect(found?.path).toEqual(["remotes", "staging", "api", "prot"]); + expect(found?.knownKeys).toContain("port"); + }); }); diff --git a/packages/config/src/validate.ts b/packages/config/src/validate.ts index 81feb0e194..4016f884af 100644 --- a/packages/config/src/validate.ts +++ b/packages/config/src/validate.ts @@ -4,9 +4,19 @@ import { isObject } from "./config-document.ts"; import { normalizeDeprecatedProviderSignupKeys } from "./lib/provider-signup.ts"; const decodeCliConfig = Schema.decodeUnknownEffect(CliConfigSchema); +const decodeCliConfigAll = Schema.decodeUnknownEffect(CliConfigSchema, { errors: "all" }); const decodeRemotesWithoutChecks = Schema.decodeUnknownEffect(RemotesSchema, { disableChecks: true, }); +const decodeRemotesWithoutChecksAll = Schema.decodeUnknownEffect(RemotesSchema, { + disableChecks: true, + errors: "all", +}); + +/** Pass `allIssues` to report every schema issue instead of stopping at the first. */ +export interface ValidateCliConfigOptions { + readonly allIssues?: true; +} /** * Decodes a complete CLI config document using the same rules as the config @@ -14,10 +24,19 @@ const decodeRemotesWithoutChecks = Schema.decodeUnknownEffect(RemotesSchema, { * structurally with business-rule checks disabled until that remote is * selected and merged into the effective config. */ -export const validateCliConfig = Effect.fn("CliConfig.validate")(function* (value: unknown) { +export const validateCliConfig = Effect.fn("CliConfig.validate")(function* ( + value: unknown, + options?: ValidateCliConfigOptions, +) { + const all = options?.allIssues === true; const folded = normalizeDeprecatedProviderSignupKeys(value).document; const document = isObject(folded) ? { ...folded, remotes: {} } : folded; - const config = yield* decodeCliConfig(document); + const config = yield* (all ? decodeCliConfigAll : decodeCliConfig)(document); const remotes = isObject(folded) ? folded.remotes : undefined; - return { ...config, remotes: yield* decodeRemotesWithoutChecks(remotes ?? {}) }; + return { + ...config, + remotes: yield* (all ? decodeRemotesWithoutChecksAll : decodeRemotesWithoutChecks)( + remotes ?? {}, + ), + }; }); diff --git a/packages/config/src/validate.unit.test.ts b/packages/config/src/validate.unit.test.ts index 1a66fa4a93..1a1b3fe596 100644 --- a/packages/config/src/validate.unit.test.ts +++ b/packages/config/src/validate.unit.test.ts @@ -130,4 +130,22 @@ describe("validateCliConfig provider switch alias", () => { expect(config.auth.email.enable_signup).toBe(true); }), )); + + test("reports every schema issue only when allIssues is set", () => + Effect.runPromise( + Effect.gen(function* () { + const document = { api: { port: "x" }, studio: { port: "y" } }; + const countIssues = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return 0; + const error = Cause.findErrorOption(exit.cause); + const issue = Option.isSome(error) ? error.value.issue : undefined; + return issue?._tag === "Composite" ? issue.issues.length : 1; + }; + const first = yield* Effect.exit(validateCliConfig(document)); + const all = yield* Effect.exit(validateCliConfig(document, { allIssues: true })); + + expect(countIssues(first)).toBe(1); + expect(countIssues(all)).toBeGreaterThan(1); + }), + )); }); From e15c211379cd913be87914f93d0ba99d682d02c3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 10 Nov 2026 13:59:31 +0000 Subject: [PATCH 02/12] fix(cli): harden config validate masking and env-reference handling - Mask code frames from parsed value spans so multi-line strings, comment-looking continuation lines and syntax-error documents never print a value; show a located value only when the location is exact. - Suppress only value errors under an unresolved env() reference, never unknown keys or SUPABASE_* override failures, and evaluate inherited base references per remote. - Report a non-table remotes or remotes. as a type error. - Name the real env file and whether it failed to read or parse. - Attribute semantic errors from SUPABASE_* overrides to the variable. - Keep key path segments so dotted table names locate correctly. --- .../src/command-internal/config-validate.ts | 9 +- .../commands/config/validate/SIDE_EFFECTS.md | 4 +- .../config/validate/validate.format.ts | 139 +++++++------ .../validate/validate.format.unit.test.ts | 89 +++++++-- .../config/validate/validate.handler.ts | 186 +++++++++++++----- .../validate/validate.integration.test.ts | 149 +++++++++++++- .../config/validate/validate.model.ts | 61 ++++-- .../validate/validate.model.unit.test.ts | 80 +++++--- apps/cli/src/shared/config/cli-config-env.ts | 8 +- .../shared/config/cli-config-load.errors.ts | 3 + packages/config/AGENTS.md | 2 +- packages/config/src/config-locate.ts | 53 +++++ .../config/src/config-locate.unit.test.ts | 38 +++- .../config/src/entrypoint-purity.unit.test.ts | 1 + packages/config/src/internal.ts | 7 +- 15 files changed, 656 insertions(+), 173 deletions(-) diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index b855a84b48..bef5b0a7e2 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -278,6 +278,8 @@ export type ConfigValidationIssueCode = */ export interface ConfigValidationIssue { readonly path: string; + /** The key path split by segment, when a segment (such as a bucket name) can contain dots. */ + readonly segments?: ReadonlyArray; readonly message: string; readonly code: ConfigValidationIssueCode; readonly reason: string; @@ -400,14 +402,15 @@ export function collectConfigValidationIssues( for (const name of input.storageBucketNames) { if (!BUCKET_NAME_PATTERN.test(name)) { - report( - invalidIssue( + report({ + ...invalidIssue( `storage.buckets.${name}`, `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${BUCKET_NAME_PATTERN.source})`, "invalid bucket name", "use lowercase letters, numbers, dots, hyphens and spaces only", ), - ); + segments: ["storage", "buckets", name], + }); } } diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index 5098ddd2c3..0d7128b801 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -55,7 +55,7 @@ Each stage reports every problem it finds; a run stops after a stage only when t ### `--output-format text` -stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****` unless the key is a known non-secret setting; `env(NAME)` references stay visible. Nothing is written to stderr by the command itself. +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. ### `--output-format json` @@ -69,5 +69,5 @@ Same structured result as `json`, delivered as an NDJSON `result` event. - No config or project file is created or changed and no Management API call is made. The CLI still writes `telemetry.json`, and the global update notice that follows every command can fetch the latest release tag and write `cli-latest` and print a notice on stderr; this command does not change that behaviour. - Every `[remotes.]` block is validated merged over the base config, whatever the linked project is. -- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and errors it causes at or below that key are not reported. +- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and value errors (type, enum, value, missing) it causes at or below that key are not reported; unknown-key, removed-key and `SUPABASE_*`-sourced errors still are. A base reference a remote inherits is checked against that remote's own gates. A `remotes` key or `remotes.` entry that is not a table is an `invalid_type` error. An error whose value a `SUPABASE_*` variable supplies names the variable and carries no file location. - `-o/--output` is rejected in favour of `--output-format`. diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index 4b2e185b14..b42b37ce15 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -1,7 +1,11 @@ import { escapeControlCharacters } from "../../../shared/output/escape-control-characters.ts"; -import { cliConfigLineKeys, type CliConfigLocation } from "@supabase/config/internal"; +import { + cliConfigLineKeys, + cliConfigValueSpans, + type CliConfigLocation, +} from "@supabase/config/internal"; -import { isPublicValuePath, type ConfigProblem } from "./validate.model.ts"; +import { displayPath, isPublicValuePath, type ConfigProblem } from "./validate.model.ts"; /** ANSI styling applied to the text report; the handler passes stream-gated colours. */ export interface ReportStyle { @@ -83,19 +87,7 @@ interface Span { const KEY_VALUE = /^(\s*(?:(?:#|\/\/)\s*)?(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+))*\s*[=:]\s*)(.*)$/; const ENV_REFERENCE_VALUE = /^["']env\([A-Za-z_][A-Za-z0-9_]*\)["']\s*,?\s*$/; -const NO_VALUE = /^\s*(?:(?:#|\/\/).*|\[\[?[^\]=]*\]\]?\s*(?:#.*)?|[\][{},\s]*|("""|''')\s*,?)\s*$/; - -/** The parts of a source line that hold a value: everything right of a key, or a bare value line. */ -function valueSpans(text: string): ReadonlyArray { - const pair = KEY_VALUE.exec(text); - if (pair?.[1] !== undefined && pair[2] !== undefined) { - const value = pair[2]; - if (ENV_REFERENCE_VALUE.test(value) || /^[[{]?\s*$/.test(value)) return []; - return [{ column: pair[1].length + 1 }]; - } - if (NO_VALUE.test(text)) return []; - return [{ column: text.length - text.trimStart().length + 1 }]; -} +const TRAILING_COMMENT = /^[\s,}\]]*(#|\/\/)/; interface MaskedLine { readonly text: string; @@ -135,36 +127,58 @@ function maskLine(text: string, spans: ReadonlyArray): MaskedLine { }; } -const maskedLine = (text: string): MaskedLine => maskLine(text, valueSpans(text)); +const isBefore = (line: number, column: number, otherLine: number, otherColumn: number) => + line < otherLine || (line === otherLine && column < otherColumn); -const TOKEN = /"(?:[^"\\]|\\.)*"|'[^']*'|[^\s,{}[\]:="']+/g; -const KEY_AHEAD = /^\s*[=:]/; - -/** The values in an inline fragment such as `{ a = 1, b = "x" }`: every token that is not a key or an `env()` reference. */ -function inlineValueSpans(text: string, from: number, to: number): ReadonlyArray { - const spans: Array = []; - for (const match of text.slice(from, to).matchAll(TOKEN)) { - const start = from + match.index; - const end = start + match[0].length; - if (KEY_AHEAD.test(text.slice(end, to)) || ENV_REFERENCE_VALUE.test(match[0])) continue; - spans.push({ column: start + 1, endColumn: end + 1 }); - } - return spans; -} +const isWithin = (value: CliConfigLocation, shown: CliConfigLocation): boolean => + !isBefore(value.line, value.column, shown.line, shown.column) && + !isBefore( + shown.endLine ?? shown.line, + shown.endColumn ?? shown.column, + value.endLine ?? value.line, + value.endColumn ?? value.column, + ); /** - * A line whose located value the report may show: the keys stay readable, but every other value on - * it is masked, since an inline table or array can hold more than the one that failed. + * A source line with every value masked, whichever lines it spans, except the values inside + * `shown`. Lines of text that cannot be parsed are masked in full. */ -function shownLine(text: string, location: CliConfigLocation | undefined): MaskedLine { - if (location === undefined) return maskedLine(text); - const pair = KEY_VALUE.exec(text); - const headStart = pair?.[1]?.length ?? text.length - text.trimStart().length; - const valueEnd = location.endColumn === undefined ? text.length : location.endColumn - 1; - return maskLine(text, [ - ...inlineValueSpans(text, headStart, location.column - 1), - ...inlineValueSpans(text, valueEnd, text.length), - ]); +function maskedAt( + { lines, valuesByLine }: SourceView, + lineNumber: number, + shown?: CliConfigLocation, +): MaskedLine { + const text = lines[lineNumber - 1] ?? ""; + const indent = text.length - text.trimStart().length; + if (text.trim() === "") return maskLine(text, []); + if (valuesByLine === undefined) return maskLine(text, [{ column: indent + 1 }]); + + const spans: Array = []; + for (const value of valuesByLine.get(lineNumber) ?? []) { + if (shown !== undefined && isWithin(value, shown)) continue; + const startsHere = value.line === lineNumber; + const endsHere = (value.endLine ?? value.line) === lineNumber; + const column = startsHere ? value.column : indent + 1; + const endColumn = endsHere ? value.endColumn : undefined; + if ( + startsHere && + endsHere && + ENV_REFERENCE_VALUE.test(text.slice(column - 1, (endColumn ?? text.length + 1) - 1)) + ) { + continue; + } + spans.push(endColumn === undefined ? { column } : { column, endColumn }); + } + if (spans.length === 0) { + const pair = /^\s*(?:#|\/\/)/.test(text) ? KEY_VALUE.exec(text) : null; + return maskLine(text, pair?.[1] === undefined ? [] : [{ column: pair[1].length + 1 }]); + } + const lastEnd = Math.max(...spans.map((span) => span.endColumn ?? text.length + 1)) - 1; + const comment = TRAILING_COMMENT.exec(text.slice(lastEnd)); + if (comment !== null) { + spans.push({ column: lastEnd + comment[0].length - comment[1]!.length + 1 }); + } + return maskLine(text, spans); } function windowAround(text: string, caretOffset: number): { text: string; caretOffset: number } { @@ -180,6 +194,8 @@ interface SourceView { readonly lines: ReadonlyArray; /** 1-based lines holding a single non-secret schema leaf, whose value context lines may show. */ readonly publicLines: ReadonlySet; + /** The value spans touching each 1-based line; undefined when the source cannot be parsed. */ + readonly valuesByLine: ReadonlyMap> | undefined; } function sourceView(report: ValidateReport): SourceView { @@ -189,16 +205,25 @@ function sourceView(report: ValidateReport): SourceView { report.source, report.format === "toml" ? "toml" : "json", )) { - if (isPublicValuePath(path.join("."))) publicLines.add(line); + if (isPublicValuePath(path)) publicLines.add(line); } - return { lines, publicLines }; + const values = cliConfigValueSpans(report.source, report.format === "toml" ? "toml" : "json"); + if (values === undefined) return { lines, publicLines, valuesByLine: undefined }; + const valuesByLine = new Map>(); + for (const value of values) { + for (let line = value.line; line <= (value.endLine ?? value.line); line++) { + valuesByLine.set(line, [...(valuesByLine.get(line) ?? []), value]); + } + } + return { lines, publicLines, valuesByLine }; } function frameLines( problem: ConfigProblem, - { lines, publicLines }: SourceView, + view: SourceView, style: ReportStyle, ): ReadonlyArray { + const { lines, publicLines } = view; const location = problem.location; const first = location === undefined ? undefined : lines[location.line - 1]; if (location === undefined || first === undefined) return []; @@ -213,8 +238,9 @@ function frameLines( const out: Array = []; if (location.line > 1) { - const previous = lines[location.line - 2] ?? ""; - const before = publicLines.has(location.line - 1) ? previous : maskedLine(previous).text; + const before = publicLines.has(location.line - 1) + ? (lines[location.line - 2] ?? "") + : maskedAt(view, location.line - 1).text; const shown = display(before).trimEnd(); const clipped = shown.length > MAX_FRAME_WIDTH ? `${shown.slice(0, MAX_FRAME_WIDTH)}…` : shown; out.push( @@ -222,10 +248,8 @@ function frameLines( ); } - const render = (text: string, isFirst: boolean) => - problem.showValue ? shownLine(text, isFirst ? location : undefined) : maskedLine(text); - - const current = render(first, true); + const shown = problem.showValue && location.exact ? location : undefined; + const current = maskedAt(view, location.line, shown); const start = current.map(location.column - 1); const singleLine = location.endLine === undefined || location.endLine === location.line; const end = @@ -242,7 +266,7 @@ function frameLines( ` ${style.dim(gutter(""))} ${" ".repeat(windowed.caretOffset)}${style.red("^".repeat(caretLength))}`, ); for (let line = location.line + 1; line <= lastLine; line++) { - const text = display(render(lines[line - 1] ?? "", false).text).trimEnd(); + const text = display(maskedAt(view, line, shown).text).trimEnd(); const clipped = text.length > MAX_FRAME_WIDTH ? `${text.slice(0, MAX_FRAME_WIDTH)}…` : text; out.push(` ${style.red(">")} ${style.dim(gutter(String(line)))} ${clipped}`); } @@ -275,7 +299,9 @@ function renderProblem( style: ReportStyle, ): ReadonlyArray { const glyph = problem.severity === "error" ? style.red("✗") : style.yellow("⚠"); - const subject = safe(problem.path ?? problem.subject ?? report.file); + const subject = safe( + problem.path === undefined ? (problem.subject ?? report.file) : displayPath(problem.path), + ); const remote = problem.remote === undefined ? "" : ` ${style.dim(`[remote: ${safe(problem.remote)}]`)}`; const out = [` ${glyph} ${style.bold(subject)} ${safe(problem.reason)}${remote}`]; @@ -338,17 +364,18 @@ export function validateReport( } function payloadProblem(problem: ConfigProblem) { + const path = problem.path === undefined ? undefined : displayPath(problem.path); const key = - problem.path === undefined + path === undefined ? undefined : problem.remote === undefined - ? problem.path - : `remotes.${problem.remote}.${problem.path}`; + ? path + : `remotes.${problem.remote}.${path}`; const { location } = problem; return { code: problem.code, severity: problem.severity, - ...(key === undefined ? {} : { key, path: problem.path }), + ...(key === undefined ? {} : { key, path }), ...(problem.remote === undefined ? {} : { remote: problem.remote }), ...(problem.subject === undefined ? {} : { file: problem.subject }), reason: problem.reason, diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index efd4b067e7..38ecfb11e2 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -45,7 +45,7 @@ describe("validateReport", () => { const text = render([ problem({ code: "unknown_key", - path: "api.prot", + path: ["api", "prot"], reason: "unknown key", help: "did you mean `port`?", location: { line: 3, column: 1, endColumn: 5, exact: true }, @@ -70,7 +70,7 @@ describe("validateReport", () => { it("masks every configured value by default, including the context line", () => { const text = render([ problem({ - path: "api.port", + path: ["api", "port"], location: { line: 5, column: 8, endColumn: 9, exact: true }, }), ]); @@ -82,7 +82,7 @@ describe("validateReport", () => { it("shows the located value only when the problem allows it, still masking siblings on the line", () => { const text = render([ problem({ - path: "api.cfg.n", + path: ["api", "cfg", "n"], showValue: true, location: { line: 6, column: 32, endColumn: 37, exact: true }, }), @@ -95,7 +95,7 @@ describe("validateReport", () => { it("shows a known non-secret key's value on the context line", () => { const text = render([ problem({ - path: "api.cfg", + path: ["api", "cfg"], location: { line: 6, column: 1, endColumn: 4, exact: true }, }), ]); @@ -106,7 +106,7 @@ describe("validateReport", () => { it("masks a secret on the context line", () => { const text = render([ problem({ - path: "api.port", + path: ["api", "port"], location: { line: 5, column: 1, endColumn: 5, exact: true }, }), ]); @@ -118,7 +118,7 @@ describe("validateReport", () => { it("masks an unknown key's value on the context line", () => { const text = render([ problem({ - path: "api.secret", + path: ["api", "secret"], location: { line: 4, column: 1, endColumn: 7, exact: true }, }), ]); @@ -126,8 +126,73 @@ describe("validateReport", () => { expect(text).toContain("3 │ prot = ****"); }); + describe("multi-line secrets", () => { + const SECRET_SOURCE = [ + "[auth.hook.send_email]", + 'secrets = """', + " # hunter2-do-not-print", + ' c2VjcmV0cGFzc3dvcmQ=="""', + "bogus = 1", + ].join("\n"); + + it("masks comment-looking and padded-base64 continuation lines on the problem's range", () => { + const text = render( + [ + problem({ + path: ["auth", "hook", "send_email", "secrets"], + location: { line: 2, column: 11, endLine: 4, endColumn: 29, exact: true }, + }), + ], + { source: SECRET_SOURCE }, + ); + + expect(text).not.toContain("hunter2"); + expect(text).not.toContain("c2Vj"); + expect(text).not.toContain("=="); + expect(text).toContain("secrets = ****"); + }); + + it("masks the closing line of a multi-line secret used as the context line", () => { + const text = render( + [ + problem({ + path: ["auth", "hook", "send_email", "bogus"], + location: { line: 5, column: 1, exact: true }, + }), + ], + { source: SECRET_SOURCE }, + ); + + expect(text).not.toContain("c2Vj"); + expect(text).not.toContain("=="); + }); + + it("masks every line of a multi-line array of secrets in JSON", () => { + const text = render( + [problem({ path: ["x"], location: { line: 2, column: 3, exact: true } })], + { + format: "json", + source: '{\n "secrets": [\n "hunter2",\n // note\n "other"\n ]\n}', + }, + ); + + expect(text).not.toContain("hunter2"); + expect(text).not.toContain("other"); + }); + + it("masks every non-blank line when the source cannot be parsed", () => { + const text = render( + [problem({ code: "syntax", location: { line: 3, column: 1, exact: true } })], + { source: 'secrets = """\n# hunter2-do-not-print\nhost = "x' }, + ); + + expect(text).not.toContain("hunter2"); + expect(text).not.toContain("host"); + }); + }); + it("labels remote problems and lists checked and skipped remotes", () => { - const text = render([problem({ path: "db.port", remote: "staging" })], { + const text = render([problem({ path: ["db", "port"], remote: "staging" })], { remotes: ["staging"], skippedRemotes: [{ name: "prod", reason: "no project id" }], }); @@ -138,7 +203,9 @@ describe("validateReport", () => { }); it("reports a valid file with a warning count", () => { - const text = render([problem({ code: "deprecated_key", severity: "warning", path: "api.x" })]); + const text = render([ + problem({ code: "deprecated_key", severity: "warning", path: ["api", "x"] }), + ]); expect(text).toContain("✓ supabase/config.toml is valid · 1 warning"); }); @@ -152,7 +219,7 @@ describe("validateReport", () => { }); it("neutralises control characters in a problem subject", () => { - const text = render([problem({ path: "a\u001b[31mb" })]); + const text = render([problem({ path: ["a\u001b[31mb"] })]); expect(text).not.toContain("\u001b"); }); @@ -173,12 +240,12 @@ describe("validateJsonPayload", () => { report( [ problem({ - path: "db.port", + path: ["db", "port"], remote: "staging", help: "use a port", location: { line: 5, column: 8, endLine: 5, endColumn: 9, exact: true }, }), - problem({ code: "deprecated_key", severity: "warning", path: "api.x" }), + problem({ code: "deprecated_key", severity: "warning", path: ["api", "x"] }), ], { remotes: ["staging"] }, ), diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index cee2468bf3..abf3e94d2f 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -9,7 +9,7 @@ import { parseCliConfigDocumentFile, type ParsedCliConfigDocument, } from "@supabase/config/internal"; -import { Effect, FileSystem, Option, Result } from "effect"; +import { Effect, FileSystem, Option, Path, Result } from "effect"; import { aqua, @@ -33,6 +33,10 @@ import { relativeConfigPath, } from "../../../command-internal/workdir-project.ts"; import { CLI_CONFIG_OPTIONAL_ENV_PATHS } from "../../../config/cli-config-key-annotations.ts"; +import { + describeCliConfigOrigin, + type CliConfigKeyOrigin, +} from "../../../config/cli-config-key.ts"; import { getDocumentValue, isDocumentRecord } from "../../../config/cli-config-document.ts"; import { flattenSchemaIssues } from "../../../config/cli-config-schema-issues.ts"; import { @@ -45,6 +49,7 @@ import { type ResolvedCliConfig, } from "../../../config/cli-config-values.service.ts"; import { type CliConfigValueError } from "../../../config/cli-config.errors.ts"; +import { type CliConfigLoadError } from "../../../shared/config/cli-config-load.errors.ts"; import { configParseCauseMessage } from "../../../shared/output/normalize-error.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; @@ -65,6 +70,7 @@ import { import { type ConfigProblem, dedupeProblems, + displayPath, envReferences, type EnvReference, isPublicValuePath, @@ -113,11 +119,11 @@ function removedKeyProblems( ): ReadonlyArray { return parsed.removedKeys.map(({ key, replacement }) => { const split = splitRemotePath(stripBrackets(key).split(".")); - const local = split.path.join("."); + const local = displayPath(split.path); const replacementLocal = splitRemotePath(stripBrackets(replacement).split(".")).path.join("."); const isTable = key.startsWith("["); return problem("removed_key", { - path: local, + path: split.path, reason: "removed", help: isTable && format === "toml" @@ -177,7 +183,7 @@ function locateProblem( if (item.location !== undefined || item.path === undefined || item.external !== undefined) { return item.location; } - const segments = item.path.split("."); + const segments = item.path; const target = KEY_TARGET_CODES.has(item.code) ? "key" : "value"; const attempts = [ ...(item.remote === undefined ? [] : [["remotes", item.remote, ...segments]]), @@ -191,10 +197,10 @@ function locateProblem( function deprecatedKeyProblems(parsed: ParsedCliConfigDocument): ReadonlyArray { return parsed.deprecatedPaths.map((dotted) => { const split = splitRemotePath(dotted.split(".")); - const local = split.path.join("."); + const local = displayPath(split.path); return problem("deprecated_key", { severity: "warning", - path: local, + path: split.path, reason: "deprecated, will be removed in v4", help: `use ${local.replace(/enable_signup$/, "enabled")} instead`, ...(split.remote === undefined ? {} : { remote: split.remote }), @@ -208,7 +214,7 @@ function unknownKeyProblems(parsed: ParsedCliConfigDocument): ReadonlyArray { - const path = issue.path.join("."); + const { path } = issue; const detail = - issue.code === "missing_required" && path !== "" - ? missingIssue(path, "") + issue.code === "missing_required" && path.length > 0 + ? missingIssue(displayPath(path), "") : { reason: issue.reason, help: issue.help }; return problem(issue.code, { - ...(path === "" ? {} : { path }), + ...(path.length === 0 ? {} : { path }), reason: detail.reason, ...(detail.help === undefined ? {} : { help: detail.help }), - showValue: issue.code !== "missing_required" && path !== "" && isPublicValuePath(path), + showValue: issue.code !== "missing_required" && path.length > 0 && isPublicValuePath(path), }); }); }); @@ -247,8 +253,9 @@ function invalidValueProblem(error: CliConfigValueError, file: string): ConfigPr : error.issues !== undefined && error.issues.length > 0 ? error.issues.join("; ") : "invalid value"; + const path = error.path.split("."); return problem("invalid_value", { - path: error.path, + path, reason, ...(error.expected?.startsWith("one of") ? { help: `use ${error.expected}` } : {}), ...(fromEnv && error.source !== undefined @@ -256,26 +263,71 @@ function invalidValueProblem(error: CliConfigValueError, file: string): ConfigPr external: true as const, help: `${error.source} sets this value, not ${file}; fix or unset it there`, } - : { showValue: isPublicValuePath(error.path) }), + : { showValue: isPublicValuePath(path) }), }); } -function semanticProblem(issue: ConfigValidationIssue): ConfigProblem { +function semanticProblem( + issue: ConfigValidationIssue, + origin: CliConfigKeyOrigin, + file: string, + describeOrigin: (origin: CliConfigKeyOrigin) => string, +): ConfigProblem { const { code } = issue; + const path = issue.segments ?? issue.path.split("."); + const fromEnv = origin.tier === "shell" || origin.tier === "projectEnv"; return problem(code, { - ...(issue.path === "" ? {} : { path: issue.path }), + ...(issue.path === "" ? {} : { path }), reason: issue.reason, - ...(issue.help === undefined ? {} : { help: issue.help }), - showValue: code !== "missing_required" && issue.path !== "" && isPublicValuePath(issue.path), + ...(fromEnv + ? { + external: true as const, + help: `${describeOrigin(origin)} sets this value, not ${file}; fix or unset it there`, + } + : { + ...(issue.help === undefined ? {} : { help: issue.help }), + showValue: code !== "missing_required" && issue.path !== "" && isPublicValuePath(path), + }), + }); +} + +function envLoadProblem(error: CliConfigLoadError): ConfigProblem { + const { file } = error; + if (file === undefined) { + return problem("env_file", { + subject: "environment variables", + reason: "could not be resolved", + help: "check the variables set in your shell", + }); + } + return problem("env_file", { + subject: file, + reason: error.fileFailure === "parse" ? "could not be parsed" : "could not be read", + help: + error.fileFailure === "parse" + ? `fix the syntax of ${file}` + : `check that ${file} is a readable file`, }); } +function remotesShapeProblems(rawRemotes: unknown): ReadonlyArray { + if (rawRemotes === undefined) return []; + if (!isDocumentRecord(rawRemotes)) { + return [problem("invalid_type", { path: ["remotes"], reason: "expected a table" })]; + } + return Object.entries(rawRemotes) + .filter(([, value]) => !isDocumentRecord(value)) + .map(([name]) => + problem("invalid_type", { path: ["remotes", name], reason: "expected a table" }), + ); +} + function remoteFailureProblem(failure: { remote: string; message: string }): ConfigProblem { const duplicate = /^duplicate project_id for \[remotes\.[^\]]+\] and \[remotes\.([^\]]+)\]$/.exec( failure.message, ); return problem("remote_project_id", { - path: "project_id", + path: ["project_id"], remote: failure.remote, reason: duplicate?.[1] === undefined @@ -296,6 +348,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { const outputFlag = yield* OutputFlag; const processControl = yield* ProcessControl; const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const configValues = yield* CliConfigValues; const { workdir } = cliSettings; @@ -405,7 +458,11 @@ export const configValidate = Effect.fn("config.validate")(function* () { const file = relativeConfigPath(workdir, parsed.path); const format = reportFormat(file, parsed.format); const rawRemotes = parsed.rawDocument?.["remotes"]; - const remoteNames = isDocumentRecord(rawRemotes) ? Object.keys(rawRemotes) : []; + const remoteNames = isDocumentRecord(rawRemotes) + ? Object.entries(rawRemotes) + .filter(([, value]) => isDocumentRecord(value)) + .map(([name]) => name) + : []; const notices: Array = [ ...removedKeyProblems(parsed, format), @@ -418,6 +475,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { }), ), ...unknownKeyProblems(parsed), + ...remotesShapeProblems(rawRemotes), ]; const declaration = parsed.schemaDeclaration; if (declaration !== undefined && declaration.version === undefined) { @@ -463,16 +521,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { case "CliConfigRemovedEnvError": return failed([problem("removed_env", { reason: error.message })]); case "CliConfigLoadError": - return failed( - [ - problem("env_file", { - subject: "supabase/.env*", - reason: "could not be read", - help: "check that the env files under supabase/ are readable files", - }), - ], - "env_file", - ); + return failed([envLoadProblem(error)], "env_file"); default: return yield* new ConfigValidateLoadError({ message: error.message }); } @@ -488,12 +537,24 @@ export const configValidate = Effect.fn("config.validate")(function* () { ...resolved.pruned, ...resolved.invalid.map((error) => error.path.split(".")), ]; + const describeOrigin = (origin: CliConfigKeyOrigin) => + describeCliConfigOrigin(origin, { workdir, path: pathService }); const passed: PassResult = { problems: withRemote( [ ...schemaProblems(resolved.schemaFailures), ...resolved.invalid.map((error) => invalidValueProblem(error, file)), - ...withoutFollowOns(semantic.map(semanticProblem), removedPaths), + ...withoutFollowOns( + semantic.map((issue) => + semanticProblem( + issue, + resolved.materialized.originAt(issue.path), + file, + describeOrigin, + ), + ), + removedPaths, + ), ], remote, ), @@ -554,42 +615,67 @@ export const configValidate = Effect.fn("config.validate")(function* () { return references.filter((reference) => values[reference.name] === undefined); }).pipe(Effect.withSpan("config.validate.env")); - const remoteWrites = (remote: string, path: string): boolean => + const remoteWrites = (remote: string, path: ReadonlyArray): boolean => + isDocumentRecord(rawRemotes) && getDocumentValue(rawRemotes[remote], path) !== undefined; + const remoteOverrides = (remote: string, path: ReadonlyArray): boolean => isDocumentRecord(rawRemotes) && - getDocumentValue(rawRemotes[remote], path.split(".")) !== undefined; + path.some((_, index) => { + const value = getDocumentValue(rawRemotes[remote], path.slice(0, index + 1)); + return value !== undefined && !isDocumentRecord(value); + }); + const inheritedBy = (reference: EnvReference, remote: string): boolean => + reference.remote === undefined && !remoteOverrides(remote, reference.path); + const unresolvedSet = new Set(unresolved); + const unresolvedFor = (remote: string | undefined): ReadonlyArray => + references.filter( + (reference) => + unresolvedSet.has(reference) && + (reference.remote === remote || (remote !== undefined && inheritedBy(reference, remote))), + ); const { kept, blamed } = withoutUnresolvedEnvErrors( dedupeProblems( [ ...notices, ...base.problems, - ...(base.resolved?.remoteFailures ?? []).map(remoteFailureProblem), + ...(base.resolved?.remoteFailures ?? []) + .filter((failure) => remoteNames.includes(failure.remote)) + .map(remoteFailureProblem), ...remotePasses.flatMap((item) => item.problems), ], remoteWrites, ), - unresolved, + unresolvedFor, ); - const isRelevantReference = (reference: EnvReference): boolean => { - if (CLI_CONFIG_OPTIONAL_ENV_PATHS[reference.path.join(".")] !== undefined) return false; - const owner = passes.get(reference.remote)?.resolved; + const isRelevantIn = (reference: EnvReference, context: string | undefined): boolean => { + if (CLI_CONFIG_OPTIONAL_ENV_PATHS[displayPath(reference.path)] !== undefined) return false; + const owner = passes.get(context)?.resolved; const gate = nearestEnabledGate(owner?.materialized.config, reference.path) ?? nearestEnabledGate(owner?.loaded.document, reference.path); return gate !== false; }; - const envWarnings = unresolved - .filter((reference) => blamed.includes(reference) || isRelevantReference(reference)) - .map((reference) => - problem("env_unresolved", { - severity: "warning", - path: reference.path.join("."), - reason: `${reference.name} is not set, so this key gets the literal text "env(${reference.name})"`, - help: `set ${reference.name} in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)`, - showValue: true, - ...(reference.remote === undefined ? {} : { remote: reference.remote }), - }), - ); + const envWarning = (reference: EnvReference, remote: string | undefined) => + problem("env_unresolved", { + severity: "warning", + path: reference.path, + reason: `${reference.name} is not set, so this key gets the literal text "env(${reference.name})"`, + help: `set ${reference.name} in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)`, + showValue: true, + ...(remote === undefined ? {} : { remote }), + }); + const envWarnings = unresolved.flatMap((reference) => { + const warns = (context: string | undefined) => + blamed.some((item) => item.reference === reference && item.remote === context) || + isRelevantIn(reference, context); + if (reference.remote !== undefined) { + return warns(reference.remote) ? [envWarning(reference, reference.remote)] : []; + } + if (warns(undefined)) return [envWarning(reference, undefined)]; + return checkedRemotes + .filter((name) => inheritedBy(reference, name) && warns(name)) + .map((name) => envWarning(reference, name)); + }); const located = [...kept, ...envWarnings].map((item) => ({ ...item, location: locateProblem(item, parsed.rawText, parsed.format), diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 7c08a73261..72ea9c6520 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -336,13 +336,13 @@ describe("config validate integration", () => { " ✗ api.prot unknown key supabase/config.jsonc:5:5 4 │ "api": { - > 5 │ "prot": **** + > 5 │ "prot": ****, │ ^^^^^^ help: did you mean \`port\`? ✗ api.max_rows expected a non-negative integer supabase/config.jsonc:6:17 - 5 │ "prot": **** + 5 │ "prot": ****, > 6 │ "max_rows": "lots", │ ^^^^^^ @@ -617,6 +617,129 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("masks the siblings of an env() reference held in an array", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[api]\nschemas = ["env(UNSET_SCHEMA)", "hunter2-do-not-print"]\n', + ), + ); + + expect(result.stdout).toContain("api.schemas.0 UNSET_SCHEMA is not set"); + expect(reportedTexts(result)).not.toContain("hunter2-do-not-print"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps an unknown key whose value is an unresolved env() reference", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[api]\nprot = "env(UNSET_PORT)"\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "unknown_key", key: "api.prot" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps an invalid SUPABASE_* override of a key holding an unresolved reference", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[db]\nport = "env(UNSET_DB_PORT)"\n'), + { format: "json", env: { SUPABASE_DB_PORT: "abc" } }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "invalid_value", key: "db.port" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports a remotes key that is not a table, and a remote that is not a table", () => + Effect.gen(function* () { + const scalar = yield* validate(tomlFile('project_id = "demo"\nremotes = 123\n'), { + format: "json", + }); + expect(Reflect.get(payloadOf(scalar) ?? {}, "errors")).toMatchObject([ + { code: "invalid_type", key: "remotes", line: 2, column: 11 }, + ]); + + const entry = yield* validate( + tomlFile('project_id = "demo"\n[remotes]\nstaging = "abcdefghijklmnopqrst"\n'), + { format: "json" }, + ); + expect(Reflect.get(payloadOf(entry) ?? {}, "errors")).toMatchObject([ + { code: "invalid_type", key: "remotes.staging", line: 3 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "warns, not fails, when a remote enables a section whose base keys are unresolved", + () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[api.tls]\nenabled = false\ncert_path = "env(UNSET_CERT)"\nkey_path = "env(UNSET_KEY)"\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.api.tls]\nenabled = true\n', + ), + ); + + expect(result.exitCode).toBeUndefined(); + expect(result.stdout).toContain("⚠ api.tls.cert_path UNSET_CERT is not set"); + expect(result.stdout).toContain("⚠ api.tls.key_path UNSET_KEY is not set"); + expect(result.stdout).toContain("[remote: prod]"); + expect(result.stdout).not.toContain("✗"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("names the real env file and failure kind when a root .env cannot be parsed", () => + Effect.gen(function* () { + yield* writeFile(".env", "!bad-line-do-not-print\n"); + const result = yield* validate(tomlFile('project_id = "demo"\n'), { format: "json" }); + + expect(payloadOf(result)).toMatchObject({ complete: false, stopped_at: "env_file" }); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "env_file", file: ".env", reason: "could not be parsed" }, + ]); + expect(reportedTexts(result)).not.toContain("bad-line-do-not-print"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "points a semantic error from a SUPABASE_* override at the variable, not the file", + () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[db]\nmajor_version = 17\n'), + { env: { SUPABASE_DB_MAJOR_VERSION: "16" } }, + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("db.major_version unsupported Postgres version"); + expect(result.stdout).toContain( + "help: SUPABASE_DB_MAJOR_VERSION (shell) sets this value, not supabase/config.toml; fix or unset it there", + ); + expect(result.stdout).not.toContain("supabase/config.toml:"); + expect(result.stdout).not.toContain("major_version = 17"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("locates a key under a table whose name contains a dot", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[storage.buckets.images]\npublic = true\n[storage.buckets.images.prod]\npublic = false\n[storage.buckets."images.prod"]\npublic = "yes"\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toContainEqual( + expect.objectContaining({ code: "invalid_type", line: 7, column: 10 }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("locates keys in a TOML file that starts with a byte order mark", () => Effect.gen(function* () { const result = yield* validate( @@ -651,13 +774,13 @@ describe("config validate integration", () => { const leaks = (result: Parameters[0]) => expect(reportedTexts(result)).not.toContain(SECRET); - it.effect("masks a secret on the context line and on a syntax error line", () => + it.effect("masks every line of a document with a syntax error", () => Effect.gen(function* () { const result = yield* validate( tomlFile(`project_id = "demo"\n[auth.email.smtp]\npass = "${SECRET}"\nhost = "x\n`), ); - expect(result.stdout).toContain("pass = ****"); + expect(result.stdout).toContain("> 4 │ ****"); leaks(result); }).pipe(Effect.provide(BunServices.layer)), ); @@ -700,6 +823,24 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect( + "masks comment-looking and padded-base64 continuation lines of a multi-line secret", + () => + Effect.gen(function* () { + const padded = "c2VjcmV0cGFzc3dvcmQ=="; + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[auth.hook.send_email]\nenabled = true\nuri = "https://x.test"\nsecrets = """\n# ${SECRET}\n${padded}"""\nbogus = 1\n`, + ), + ); + + expect(result.stdout).toContain("auth.hook.send_email.bogus"); + expect(result.stdout).toContain("bogus = ****"); + leaks(result); + expect(result.stdout).not.toContain(padded.slice(0, 8)); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("masks db.root_key and a hook secret in JSON mode and in JSONC frames", () => Effect.gen(function* () { const result = yield* validate({ diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts index 4ab98cca7c..24571581e4 100644 --- a/apps/cli/src/commands/config/validate/validate.model.ts +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -23,8 +23,8 @@ type ProblemCode = export interface ConfigProblem { readonly code: ProblemCode; readonly severity: "error" | "warning"; - /** The key's path with any `remotes..` prefix removed. */ - readonly path?: string; + /** The key's path segments with any `remotes..` prefix removed. */ + readonly path?: ReadonlyArray; readonly remote?: string; /** Names the file a pathless problem is about when it is not the validated one. */ readonly subject?: string; @@ -77,8 +77,8 @@ const isPublicFamilyLeaf = (segments: ReadonlyArray): boolean => * Whether the value at a config key path may be printed: a schema leaf that is neither secret nor * secret-looking. Everything else, including keys the schema does not know, is masked. */ -export function isPublicValuePath(path: string): boolean { - const segments = splitRemotePath(path.split(".")).path; +export function isPublicValuePath(path: ReadonlyArray): boolean { + const segments = splitRemotePath(path).path; const local = segments.join("."); return ( local !== ROOT_KEY_PATH && @@ -88,10 +88,13 @@ export function isPublicValuePath(path: string): boolean { ); } +/** A key path as shown to people; segments that contain dots are indistinguishable from nesting. */ +export const displayPath = (path: ReadonlyArray): string => path.join("."); + const keyOf = (problem: ConfigProblem): string => - `${problem.code}\0${problem.remote ?? ""}\0${problem.path ?? ""}\0${problem.subject ?? ""}`; + `${problem.code}\0${problem.remote ?? ""}\0${problem.path?.join("\0") ?? ""}\0${problem.subject ?? ""}`; -const KEYED_BY_PATH: ReadonlySet = new Set([ +const VALUE_LEVEL_CODES: ReadonlySet = new Set([ "invalid_type", "invalid_enum", "invalid_value", @@ -105,7 +108,7 @@ const KEYED_BY_PATH: ReadonlySet = new Set([ */ export function dedupeProblems( problems: ReadonlyArray, - remoteWrites: (remote: string, path: string) => boolean, + remoteWrites: (remote: string, path: ReadonlyArray) => boolean, ): ReadonlyArray { const base = new Set( problems @@ -118,9 +121,9 @@ export function dedupeProblems( const inherited = problem.remote !== undefined && base.has(`${keyOf({ ...problem, remote: undefined })}\0${problem.reason}`) && - !remoteWrites(problem.remote, problem.path ?? ""); + !remoteWrites(problem.remote, problem.path ?? []); if (inherited) return false; - const identity = KEYED_BY_PATH.has(problem.code) ? keyOf(problem) : full; + const identity = VALUE_LEVEL_CODES.has(problem.code) ? keyOf(problem) : full; if (seen.has(identity)) return false; seen.add(identity); return true; @@ -138,7 +141,7 @@ export function withoutFollowOns( return problems.filter( (problem) => problem.path === undefined || - !removed.some((prefix) => atOrBelow(problem.path?.split(".") ?? [], prefix)), + !removed.some((prefix) => atOrBelow(problem.path ?? [], prefix)), ); } @@ -172,24 +175,40 @@ export function envReferences(document: unknown): ReadonlyArray { } /** - * Drops errors that sit at or below a key whose `env(NAME)` reference could not be resolved in the - * same pass, and names the references that caused a drop. + * Drops value-level errors that sit at or below a key whose `env(NAME)` reference could not be + * resolved, since the literal reference text is what fails, and names the references that caused a + * drop. Errors about the key itself, and values an env variable supplied, always stay. */ export function withoutUnresolvedEnvErrors( problems: ReadonlyArray, - unresolved: ReadonlyArray, -): { readonly kept: ReadonlyArray; readonly blamed: ReadonlyArray } { - const blamed = new Set(); + unresolvedFor: (remote: string | undefined) => ReadonlyArray, +): { + readonly kept: ReadonlyArray; + readonly blamed: ReadonlyArray<{ readonly reference: EnvReference; readonly remote?: string }>; +} { + const blamed: Array<{ readonly reference: EnvReference; readonly remote?: string }> = []; const kept = problems.filter((problem) => { - if (problem.severity !== "error" || problem.path === undefined) return true; - const segments = problem.path.split("."); - const owners = unresolved.filter( - (reference) => reference.remote === problem.remote && atOrBelow(segments, reference.path), + if ( + problem.severity !== "error" || + problem.path === undefined || + problem.external !== undefined || + !VALUE_LEVEL_CODES.has(problem.code) + ) { + return true; + } + const { path } = problem; + const owners = unresolvedFor(problem.remote).filter((reference) => + atOrBelow(path, reference.path), ); - for (const owner of owners) blamed.add(owner); + for (const reference of owners) { + blamed.push({ + reference, + ...(problem.remote === undefined ? {} : { remote: problem.remote }), + }); + } return owners.length === 0; }); - return { kept, blamed: [...blamed] }; + return { kept, blamed }; } const readPath = (root: unknown, path: ReadonlyArray): unknown => { diff --git a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts index df356eb721..aff0fb0867 100644 --- a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts @@ -40,7 +40,7 @@ describe("isPublicValuePath", () => { "auth.external.github.client_id", "remotes.staging.db.port", ])("shows the value of %s", (path) => { - expect(isPublicValuePath(path)).toBe(true); + expect(isPublicValuePath(path.split("."))).toBe(true); }); it.each([ @@ -52,43 +52,60 @@ describe("isPublicValuePath", () => { "auth.hook.send_email.secrets", "api.not_a_key", ])("masks the value of %s", (path) => { - expect(isPublicValuePath(path)).toBe(false); + expect(isPublicValuePath(path.split("."))).toBe(false); }); }); describe("dedupeProblems", () => { it("drops a remote problem the base already reports", () => { - const base = problem({ code: "invalid_value", path: "db.port", reason: "expected a port" }); + const base = problem({ + code: "invalid_value", + path: ["db", "port"], + reason: "expected a port", + }); const remote = { ...base, remote: "staging" }; expect(dedupeProblems([base, remote], noRemoteWrites)).toEqual([base]); }); it("keeps a remote problem on a key the remote writes itself", () => { - const base = problem({ code: "invalid_value", path: "db.port", reason: "expected a port" }); + const base = problem({ + code: "invalid_value", + path: ["db", "port"], + reason: "expected a port", + }); const remote = { ...base, remote: "staging" }; expect( - dedupeProblems([base, remote], (name, path) => name === "staging" && path === "db.port"), + dedupeProblems( + [base, remote], + (name, path) => name === "staging" && path.join(".") === "db.port", + ), ).toEqual([base, remote]); }); it("reports a value-level key once even when the reason differs", () => { - const first = problem({ code: "invalid_value", path: "db.port", reason: "one" }); - const second = problem({ code: "invalid_value", path: "db.port", reason: "two" }); + const first = problem({ code: "invalid_value", path: ["db", "port"], reason: "one" }); + const second = problem({ code: "invalid_value", path: ["db", "port"], reason: "two" }); expect(dedupeProblems([first, second], noRemoteWrites)).toEqual([first]); }); it("keeps the same key reported under different remotes", () => { - const a = problem({ code: "invalid_value", path: "db.port", remote: "a" }); - const b = problem({ code: "invalid_value", path: "db.port", remote: "b" }); + const a = problem({ code: "invalid_value", path: ["db", "port"], remote: "a" }); + const b = problem({ code: "invalid_value", path: ["db", "port"], remote: "b" }); expect(dedupeProblems([a, b], (name) => name !== "")).toEqual([a, b]); }); }); describe("withoutFollowOns", () => { it("drops problems at or below a pruned path and keeps the rest", () => { - const below = problem({ code: "missing_required", path: "auth.external.github.client_id" }); - const at = problem({ code: "invalid_value", path: "auth.external.github" }); - const other = problem({ code: "invalid_value", path: "auth.external.gitlab.client_id" }); + const below = problem({ + code: "missing_required", + path: ["auth", "external", "github", "client_id"], + }); + const at = problem({ code: "invalid_value", path: ["auth", "external", "github"] }); + const other = problem({ + code: "invalid_value", + path: ["auth", "external", "gitlab", "client_id"], + }); const pathless = problem({ code: "removed_env" }); expect( withoutFollowOns([below, at, other, pathless], [["auth", "external", "github"]]), @@ -115,26 +132,45 @@ describe("envReferences", () => { describe("withoutUnresolvedEnvErrors", () => { const baseRef = { name: "A", path: ["api"] }; const remoteRef = { name: "B", path: ["api"], remote: "prod" }; + const referencesFor = + (...references: ReadonlyArray) => + (remote: string | undefined) => + references.filter((reference) => Reflect.get(reference, "remote") === remote); - it("drops errors at or below an unresolved reference of the same pass", () => { - const base = problem({ code: "invalid_value", path: "api.max_rows" }); - const remote = problem({ code: "invalid_value", path: "api.max_rows", remote: "prod" }); - const result = withoutUnresolvedEnvErrors([base, remote], [baseRef]); + it("drops value errors at or below an unresolved reference of the same pass", () => { + const base = problem({ code: "invalid_value", path: ["api", "max_rows"] }); + const remote = problem({ code: "invalid_value", path: ["api", "max_rows"], remote: "prod" }); + const result = withoutUnresolvedEnvErrors([base, remote], referencesFor(baseRef)); expect(result.kept).toEqual([remote]); - expect(result.blamed).toEqual([baseRef]); + expect(result.blamed).toEqual([{ reference: baseRef }]); }); it("lets a remote reference drop only that remote's errors", () => { - const base = problem({ code: "invalid_value", path: "api.max_rows" }); - const remote = problem({ code: "invalid_value", path: "api.max_rows", remote: "prod" }); + const base = problem({ code: "invalid_value", path: ["api", "max_rows"] }); + const remote = problem({ code: "invalid_value", path: ["api", "max_rows"], remote: "prod" }); - expect(withoutUnresolvedEnvErrors([base, remote], [remoteRef]).kept).toEqual([base]); + expect(withoutUnresolvedEnvErrors([base, remote], referencesFor(remoteRef)).kept).toEqual([ + base, + ]); + }); + + it("keeps errors that are not about the value the reference stands for", () => { + const unknown = problem({ code: "unknown_key", path: ["api", "prot"] }); + const removed = problem({ code: "removed_key", path: ["api", "old"] }); + const external = problem({ code: "invalid_value", path: ["api", "port"], external: true }); + const problems = [unknown, removed, external]; + + expect(withoutUnresolvedEnvErrors(problems, referencesFor(baseRef)).kept).toEqual(problems); }); it("never drops warnings", () => { - const warning = problem({ code: "deprecated_key", severity: "warning", path: "api.max_rows" }); - expect(withoutUnresolvedEnvErrors([warning], [baseRef]).kept).toEqual([warning]); + const warning = problem({ + code: "deprecated_key", + severity: "warning", + path: ["api", "max_rows"], + }); + expect(withoutUnresolvedEnvErrors([warning], referencesFor(baseRef)).kept).toEqual([warning]); }); }); diff --git a/apps/cli/src/shared/config/cli-config-env.ts b/apps/cli/src/shared/config/cli-config-env.ts index 6557d729bd..431a1036be 100644 --- a/apps/cli/src/shared/config/cli-config-env.ts +++ b/apps/cli/src/shared/config/cli-config-env.ts @@ -134,6 +134,8 @@ export const loadCliProjectEnvFiles = Effect.fn("CliConfigEnv.load")(function* ( : Effect.fail( new CliConfigLoadError({ message: `failed to read environment file: ${displayPath}`, + file: displayPath, + fileFailure: "read", }), ), ), @@ -143,7 +145,11 @@ export const loadCliProjectEnvFiles = Effect.fn("CliConfigEnv.load")(function* ( const parsed = yield* Effect.try({ try: () => parseDotEnv(content.value), catch: () => - new CliConfigLoadError({ message: `failed to parse environment file: ${displayPath}` }), + new CliConfigLoadError({ + message: `failed to parse environment file: ${displayPath}`, + file: displayPath, + fileFailure: "parse", + }), }); for (const [key, value] of Object.entries(parsed)) { if (Object.hasOwn(values, key)) continue; diff --git a/apps/cli/src/shared/config/cli-config-load.errors.ts b/apps/cli/src/shared/config/cli-config-load.errors.ts index 31c5a8bd9d..77d58e83e6 100644 --- a/apps/cli/src/shared/config/cli-config-load.errors.ts +++ b/apps/cli/src/shared/config/cli-config-load.errors.ts @@ -11,6 +11,9 @@ export class CliConfigLoadError extends Data.TaggedError("CliConfigLoadError")<{ readonly message: string; /** The `[remotes.]` block the failure is about, when it is about one. */ readonly remote?: string; + /** The project env file, relative to the workdir, a read or parse failure is about. */ + readonly file?: string; + readonly fileFailure?: "read" | "parse"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index fa2b5793d5..0579bbc678 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -39,7 +39,7 @@ artifacts (`./schema.json`, `./project-schema.json`). otherwise-internal registry data (`AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, `ProjectConfigMappingRow`, `ProjectConfigApiAttributes`, `ENV_CAPTURE_REGEX`, and `withoutSafeIntegerBounds` for the CLI's published lock schema), the - JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `locateCliConfigKey`/`CliConfigLocation` (line/column of a key or value in raw TOML/JSON/JSONC text), `cliConfigLineKeys` (the key path each single-entry line holds) and `isSecretPath`. It also carries + JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `locateCliConfigKey`/`CliConfigLocation` (line/column of a key or value in raw TOML/JSON/JSONC text), `cliConfigLineKeys` (the key path each single-entry line holds), `cliConfigValueSpans` (the span of every scalar or array value, multi-line ones included) and `isSecretPath`. It also carries `supabase config pull`/`diff`'s own support surface (CLI-2064): the format-preserving surgical editor (`applyConfigEdits` and its `ConfigEdit`/`ConfigEditOutcome`/`ConfigEditRefusal`/`ConfigEditRefusalReason`/`ConfigEditValue`/ diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index b4fbb7b512..c241c02ad8 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -378,3 +378,56 @@ export function cliConfigLineKeys( const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; return format === "toml" ? tomlLineKeys(source) : jsonLineKeys(source); } + +function tomlValueSpans(source: string, span: Span): ReadonlyArray { + if (source[span.start] !== "{") return [span]; + const entries = inlineTableEntries(source, span); + return entries === undefined + ? [span] + : entries.flatMap((entry) => tomlValueSpans(source, entry.value)); +} + +function jsonValueSpans(root: Node): ReadonlyArray { + const found: Array = []; + const walk = (node: Node): void => { + if (node.type === "object") { + for (const property of node.children ?? []) { + const value = property.children?.[1]; + if (value !== undefined) walk(value); + } + } else if (node.type === "array") { + for (const child of node.children ?? []) walk(child); + } else { + found.push({ start: node.offset, end: node.offset + node.length }); + } + }; + walk(root); + return found; +} + +/** + * The span of every scalar or array value in raw config text, including ones that cover several + * lines; undefined when the text cannot be parsed. + */ +export function cliConfigValueSpans( + text: string, + format: ConfigFormat, +): ReadonlyArray | undefined { + const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + if (format === "toml") { + const { scan, starts } = scanOnce(source); + if (isScanError(scan)) return undefined; + return scan.tokens + .filter((token): token is TomlKeyToken => token.kind === "kv") + .flatMap((token) => tomlValueSpans(source, { start: token.valueStart, end: token.valueEnd })) + .filter((span) => span.end > span.start) + .map((span) => toLocation(starts, span, true)); + } + const errors: Array = []; + const root = parseTree(source, errors, { allowTrailingComma: true, disallowComments: false }); + if (root === undefined || errors.length > 0) return undefined; + const starts = lineStarts(source); + return jsonValueSpans(root) + .filter((span) => span.end > span.start) + .map((span) => toLocation(starts, span, true)); +} diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index b819db5ec1..61e1dd9f33 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { cliConfigLineKeys, locateCliConfigKey } from "./config-locate.ts"; +import { cliConfigLineKeys, cliConfigValueSpans, locateCliConfigKey } from "./config-locate.ts"; import { isSecretPath } from "./lib/secret-paths.ts"; const toml = [ @@ -220,3 +220,39 @@ describe("cliConfigLineKeys", () => { expect(cliConfigLineKeys('{ "api": { "port": 1 ', "json").size).toBe(0); }); }); + +describe("cliConfigValueSpans", () => { + const textOf = (source: string, spans: ReturnType) => + (spans ?? []).map((span) => { + const lines = source.split("\n"); + const first = lines[span.line - 1] ?? ""; + if (span.endLine === undefined || span.endLine === span.line) { + return first.slice(span.column - 1, (span.endColumn ?? 1) - 1); + } + return [ + first.slice(span.column - 1), + ...lines.slice(span.line, span.endLine - 1), + (lines[span.endLine - 1] ?? "").slice(0, (span.endColumn ?? 1) - 1), + ].join("\n"); + }); + + test("covers a multi-line toml string and each value of an inline table", () => { + const source = 'a = """\n# x\nb=="""\nc = { d = 1, e = "two" }\nf = [1, 2]\n'; + expect(textOf(source, cliConfigValueSpans(source, "toml"))).toEqual([ + '"""\n# x\nb=="""', + "1", + '"two"', + "[1, 2]", + ]); + }); + + test("covers each json scalar, including those of a multi-line array", () => { + const source = '{\n "a": [\n "x",\n 2\n ],\n "b": { "c": true }\n}'; + expect(textOf(source, cliConfigValueSpans(source, "json"))).toEqual(['"x"', "2", "true"]); + }); + + test("is undefined when the text does not parse", () => { + expect(cliConfigValueSpans('a = """\nx', "toml")).toBeUndefined(); + expect(cliConfigValueSpans('{ "a": ', "json")).toBeUndefined(); + }); +}); diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index d06c1179cc..6bd0b7a361 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -528,6 +528,7 @@ describe("src/internal.ts export surface", () => { "applyConfigEdits", "cliConfigFormatForPath", "cliConfigLineKeys", + "cliConfigValueSpans", "decodeCliConfigDocumentForValidationEffect", "decodeMergedCliConfig", "dualScopeProjectConfigPaths", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 560b2d32d8..c1f6456293 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -54,4 +54,9 @@ export { PROVIDER_SIGNUP_ALIASES } from "./lib/provider-signup.ts"; export { CliConfigWriteError } from "./errors.ts"; export { findUnknownCliConfigKeys, type UnknownCliConfigKey } from "./unknown-keys.ts"; export { isSecretPath } from "./lib/secret-paths.ts"; -export { cliConfigLineKeys, locateCliConfigKey, type CliConfigLocation } from "./config-locate.ts"; +export { + cliConfigLineKeys, + cliConfigValueSpans, + locateCliConfigKey, + type CliConfigLocation, +} from "./config-locate.ts"; From a1fc13e3c98cb53ba4969f16f079d3f9fd16c14e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 10 Nov 2026 14:27:58 +0000 Subject: [PATCH 03/12] fix(cli): mask nested and URL-embedded secrets in config validate - Show a located value only when it is a scalar or a flat array of scalars, and redact URL credentials and query strings in any shown value or context line. - Skip a remote whose project_id is an unset env() reference with a warning, and locate remote project_id failures inside that remote. - Stay quiet about an unresolved reference a SUPABASE_* variable overrides, and report a removed env variable as an incomplete run. - Name the bigquery backend and auth.passkey.enabled in missing-key help. - Clamp the caret to the windowed frame and read the source once on a schema-version stop. --- apps/cli/docs/supabase/config/validate.md | 2 +- .../src/command-internal/config-validate.ts | 20 +-- .../commands/config/validate/SIDE_EFFECTS.md | 6 +- .../config/validate/validate.format.ts | 75 +++++++++- .../validate/validate.format.unit.test.ts | 112 ++++++++++++++ .../config/validate/validate.handler.ts | 125 +++++++++++----- .../validate/validate.integration.test.ts | 139 ++++++++++++++++++ apps/cli/src/config/cli-config-remote.ts | 2 +- 8 files changed, 423 insertions(+), 58 deletions(-) diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md index 08e37e8c3f..358b62c809 100644 --- a/apps/cli/docs/supabase/config/validate.md +++ b/apps/cli/docs/supabase/config/validate.md @@ -7,7 +7,7 @@ Checks the project config file (`supabase/config.toml`, `config.json` or `config 1. The file parses (TOML or JSONC). 2. The schema version is one this CLI reads. 3. Only one config file applies: `config.json` and `config.jsonc` together is an error, and a JSON file next to `config.toml` is reported as shadowed. -4. The env files load, so `env(NAME)` references and `SUPABASE_*` overrides resolve. +4. The env files load and no removed `SUPABASE_*` variable is set, so `env(NAME)` references and overrides resolve. 5. Removed keys, unknown keys (with a `did you mean` suggestion when one is close), values of the wrong type, enum or range, and missing required keys. 6. The validations `supabase start` applies, such as a missing provider secret or an unreadable `content_path` file. 7. Every `[remotes.]` block, merged over the base config. Its problems carry the remote name. diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index bef5b0a7e2..7f2dba0968 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -287,6 +287,7 @@ export interface ConfigValidationIssue { } const lastSegment = (path: string): string => path.split(".").at(-1) ?? path; +const parentOf = (path: string): string => path.split(".").slice(0, -1).join("."); const GATE_PATTERNS: ReadonlyArray string]> = [ [/^auth\.email\.smtp\./, () => "auth.email.smtp.enabled"], @@ -328,7 +329,7 @@ export function missingIssue( message, code: "missing_required", reason: `required when ${gate} = true`, - help: `set ${lastSegment(path)}, or set ${lastSegment(gate)} = false`, + help: `set ${lastSegment(path)}, or set ${parentOf(gate) === parentOf(path) ? lastSegment(gate) : gate} = false`, }; } @@ -711,17 +712,18 @@ export function collectConfigValidationIssues( ), ); } else if (input.analytics.enabled && backend === "bigquery") { + const missingForBigquery = (path: string, key: string): void => { + report({ + ...missingIssue(path, `Missing required field in config: ${path}`, "analytics.enabled"), + reason: "required when analytics.backend = bigquery", + help: `set ${key}, or set backend = "postgres"`, + }); + }; if (input.analytics.gcpProjectId.length === 0) { - missing( - "analytics.gcp_project_id", - "Missing required field in config: analytics.gcp_project_id", - ); + missingForBigquery("analytics.gcp_project_id", "gcp_project_id"); } if (input.analytics.gcpProjectNumber.length === 0) { - missing( - "analytics.gcp_project_number", - "Missing required field in config: analytics.gcp_project_number", - ); + missingForBigquery("analytics.gcp_project_number", "gcp_project_number"); } if (input.analytics.gcpJwtPath.length === 0) { report({ diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index 0d7128b801..8b99fcf657 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -49,13 +49,13 @@ Telemetry records a run that reports problems as a successful command, because t ## Run order and stops -Each stage reports every problem it finds; a run stops after a stage only when the later stages cannot be computed: `syntax`, `schema_version`, `file_conflict`, `env_file`. Remote blocks, unknown keys, removed keys, type and value errors and the semantic checks `supabase start` applies are all reported in one pass. JSON output sets `complete: false` and `stopped_at` when a run stopped early. +Each stage reports every problem it finds; a run stops after a stage only when the later stages cannot be computed: `syntax`, `schema_version`, `file_conflict`, `env_file`, `removed_env`. Remote blocks, unknown keys, removed keys, type and value errors and the semantic checks `supabase start` applies are all reported in one pass. JSON output sets `complete: false` and `stopped_at` when a run stopped early. ## Output ### `--output-format text` -stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting, and then only a scalar or flat array is shown, with URL credentials and query strings redacted; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. ### `--output-format json` @@ -69,5 +69,5 @@ Same structured result as `json`, delivered as an NDJSON `result` event. - No config or project file is created or changed and no Management API call is made. The CLI still writes `telemetry.json`, and the global update notice that follows every command can fetch the latest release tag and write `cli-latest` and print a notice on stderr; this command does not change that behaviour. - Every `[remotes.]` block is validated merged over the base config, whatever the linked project is. -- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and value errors (type, enum, value, missing) it causes at or below that key are not reported; unknown-key, removed-key and `SUPABASE_*`-sourced errors still are. A base reference a remote inherits is checked against that remote's own gates. A `remotes` key or `remotes.` entry that is not a table is an `invalid_type` error. An error whose value a `SUPABASE_*` variable supplies names the variable and carries no file location. +- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and value errors (type, enum, value, missing) it causes at or below that key are not reported; unknown-key, removed-key and `SUPABASE_*`-sourced errors still are. A remote whose `project_id` is an unset `env()` reference is skipped with a warning, and a reference a `SUPABASE_*` variable overrides is not reported. A base reference a remote inherits is checked against that remote's own gates. A `remotes` key or `remotes.` entry that is not a table is an `invalid_type` error. An error whose value a `SUPABASE_*` variable supplies names the variable and carries no file location. - `-o/--output` is rejected in favour of `--output-format`. diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index b42b37ce15..1391743937 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -28,13 +28,14 @@ export const PLAIN_REPORT_STYLE: ReportStyle = { code: plain, }; -export type StopStage = "syntax" | "schema_version" | "file_conflict" | "env_file"; +export type StopStage = "syntax" | "schema_version" | "file_conflict" | "env_file" | "removed_env"; const STOP_LABELS: Readonly> = { syntax: "the syntax check", schema_version: "the schema version check", file_conflict: "the config file check", env_file: "loading the env files", + removed_env: "resolving the environment variables", }; const PAYLOAD_VERSION = 1; @@ -87,6 +88,8 @@ interface Span { const KEY_VALUE = /^(\s*(?:(?:#|\/\/)\s*)?(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+))*\s*[=:]\s*)(.*)$/; const ENV_REFERENCE_VALUE = /^["']env\([A-Za-z_][A-Za-z0-9_]*\)["']\s*,?\s*$/; +const URL_USERINFO = /[a-z][a-z0-9+.-]*:\/\/([^\s/?#"']*)@/gi; +const URL_QUERY = /[a-z][a-z0-9+.-]*:\/\/[^\s"'?#]*\?([^\s"'#]*)/gi; const TRAILING_COMMENT = /^[\s,}\]]*(#|\/\/)/; interface MaskedLine { @@ -127,6 +130,50 @@ function maskLine(text: string, spans: ReadonlyArray): MaskedLine { }; } +/** The credentials and query string of every URL in `text` from `from` to `to` (1-based, `to` exclusive). */ +function urlSpans(text: string, from: number, to: number): ReadonlyArray { + const slice = text.slice(from - 1, to - 1); + const spans: Array = []; + for (const match of slice.matchAll(URL_USERINFO)) { + const userinfo = match[1] ?? ""; + const start = from + match.index + match[0].length - 1 - userinfo.length; + if (userinfo.length > 0) spans.push({ column: start, endColumn: start + userinfo.length }); + } + for (const match of slice.matchAll(URL_QUERY)) { + const query = match[1] ?? ""; + const start = from + match.index + match[0].length - query.length; + if (query.length > 0) spans.push({ column: start, endColumn: start + query.length }); + } + return spans; +} + +/** A scalar or a flat array of scalars; a table, or anything nested, may hold values the problem is not about. */ +function isShowable(lines: ReadonlyArray, location: CliConfigLocation): boolean { + const first = lines[location.line - 1] ?? ""; + const last = location.endLine ?? location.line; + const text = + last === location.line + ? first.slice(location.column - 1, (location.endColumn ?? first.length + 1) - 1) + : [ + first.slice(location.column - 1), + ...lines.slice(location.line, last - 1), + (lines[last - 1] ?? "").slice(0, (location.endColumn ?? 1) - 1), + ].join("\n"); + const head = text.trimStart()[0]; + if (head === "{") return false; + if (head !== "[") return true; + let quote: string | undefined; + for (let index = text.indexOf("[") + 1; index < text.length; index++) { + const character = text[index]; + if (quote !== undefined) { + if (character === "\\" && quote === '"') index++; + else if (character === quote) quote = undefined; + } else if (character === '"' || character === "'") quote = character; + else if (character === "{" || character === "[") return false; + } + return true; +} + const isBefore = (line: number, column: number, otherLine: number, otherColumn: number) => line < otherLine || (line === otherLine && column < otherColumn); @@ -154,12 +201,16 @@ function maskedAt( if (valuesByLine === undefined) return maskLine(text, [{ column: indent + 1 }]); const spans: Array = []; + const redactions: Array = []; for (const value of valuesByLine.get(lineNumber) ?? []) { - if (shown !== undefined && isWithin(value, shown)) continue; const startsHere = value.line === lineNumber; const endsHere = (value.endLine ?? value.line) === lineNumber; const column = startsHere ? value.column : indent + 1; const endColumn = endsHere ? value.endColumn : undefined; + if (shown !== undefined && isWithin(value, shown)) { + redactions.push(...urlSpans(text, column, endColumn ?? text.length + 1)); + continue; + } if ( startsHere && endsHere && @@ -171,14 +222,17 @@ function maskedAt( } if (spans.length === 0) { const pair = /^\s*(?:#|\/\/)/.test(text) ? KEY_VALUE.exec(text) : null; - return maskLine(text, pair?.[1] === undefined ? [] : [{ column: pair[1].length + 1 }]); + return maskLine(text, [ + ...redactions, + ...(pair?.[1] === undefined ? [] : [{ column: pair[1].length + 1 }]), + ]); } const lastEnd = Math.max(...spans.map((span) => span.endColumn ?? text.length + 1)) - 1; const comment = TRAILING_COMMENT.exec(text.slice(lastEnd)); if (comment !== null) { spans.push({ column: lastEnd + comment[0].length - comment[1]!.length + 1 }); } - return maskLine(text, spans); + return maskLine(text, [...spans, ...redactions]); } function windowAround(text: string, caretOffset: number): { text: string; caretOffset: number } { @@ -238,8 +292,9 @@ function frameLines( const out: Array = []; if (location.line > 1) { + const previous = lines[location.line - 2] ?? ""; const before = publicLines.has(location.line - 1) - ? (lines[location.line - 2] ?? "") + ? maskLine(previous, urlSpans(previous, 1, previous.length + 1)).text : maskedAt(view, location.line - 1).text; const shown = display(before).trimEnd(); const clipped = shown.length > MAX_FRAME_WIDTH ? `${shown.slice(0, MAX_FRAME_WIDTH)}…` : shown; @@ -248,7 +303,8 @@ function frameLines( ); } - const shown = problem.showValue && location.exact ? location : undefined; + const shown = + problem.showValue && location.exact && isShowable(lines, location) ? location : undefined; const current = maskedAt(view, location.line, shown); const start = current.map(location.column - 1); const singleLine = location.endLine === undefined || location.endLine === location.line; @@ -259,8 +315,13 @@ function frameLines( ? start + 1 : current.text.length; const caretOffset = display(current.text.slice(0, start)).length; - const caretLength = Math.max(1, display(current.text.slice(start, end)).length); const windowed = windowAround(display(current.text).trimEnd(), caretOffset); + const visible = + windowed.text.length - windowed.caretOffset - (windowed.text.endsWith("…") ? 1 : 0); + const caretLength = Math.max( + 1, + Math.min(display(current.text.slice(start, end)).length, visible), + ); out.push(` ${style.red(">")} ${style.dim(gutter(String(location.line)))} ${windowed.text}`); out.push( ` ${style.dim(gutter(""))} ${" ".repeat(windowed.caretOffset)}${style.red("^".repeat(caretLength))}`, diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index 38ecfb11e2..b8dcec6638 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -191,6 +191,118 @@ describe("validateReport", () => { }); }); + describe("shown values", () => { + it("masks the nested values of an inline table at a shown location", () => { + const source = '[api]\nmax_rows = { secret = "hunter2" }'; + const text = render( + [ + problem({ + path: ["api", "max_rows"], + showValue: true, + location: { line: 2, column: 12, endColumn: 39, exact: true }, + }), + ], + { source }, + ); + + expect(text).not.toContain("hunter2"); + }); + + it("masks a table nested in a shown array", () => { + const source = '[api]\nschemas = ["a", { secret = "hunter2" }]'; + const text = render( + [ + problem({ + path: ["api", "schemas"], + showValue: true, + location: { line: 2, column: 11, endColumn: 44, exact: true }, + }), + ], + { source }, + ); + + expect(text).not.toContain("hunter2"); + }); + + it("shows a flat array of scalars", () => { + const source = '[api]\nschemas = ["a", "b"]'; + const text = render( + [ + problem({ + path: ["api", "schemas"], + showValue: true, + location: { line: 2, column: 11, endColumn: 21, exact: true }, + }), + ], + { source }, + ); + + expect(text).toContain('schemas = ["a", "b"]'); + }); + + const URL_SOURCE = [ + "[studio]", + 'api_url = "https://user:pw-secret@host.test/x?token=tok-secret#top"', + 'port = "bad"', + ].join("\n"); + + it("redacts credentials and the query string of a shown problem value", () => { + const text = render( + [ + problem({ + path: ["studio", "api_url"], + showValue: true, + location: { + line: 2, + column: 11, + endColumn: (URL_SOURCE.split("\n")[1]?.length ?? 0) + 1, + exact: true, + }, + }), + ], + { source: URL_SOURCE }, + ); + + expect(text).toContain('api_url = "https://****@host.test/x?****#top"'); + expect(text).not.toContain("pw-secret"); + expect(text).not.toContain("tok-secret"); + }); + + it("redacts a public URL on the context line", () => { + const text = render( + [ + problem({ + path: ["studio", "port"], + location: { line: 3, column: 8, endColumn: 13, exact: true }, + }), + ], + { source: URL_SOURCE }, + ); + + expect(text).toContain('api_url = "https://****@host.test/x?****#top"'); + expect(text).not.toContain("pw-secret"); + expect(text).not.toContain("tok-secret"); + }); + }); + + it("keeps the caret inside a windowed long value", () => { + const source = `[api]\nschemas = "${"x".repeat(300)}"`; + const text = render( + [ + problem({ + path: ["api", "schemas"], + showValue: true, + location: { line: 2, column: 11, endColumn: 313, exact: true }, + }), + ], + { source }, + ); + const carets = text.split("\n").find((line) => line.includes("^")) ?? ""; + const frame = text.split("\n").find((line) => line.startsWith(" > 2")) ?? ""; + + expect(carets.trimEnd().length).toBeLessThanOrEqual(frame.length); + }); + it("labels remote problems and lists checked and skipped remotes", () => { const text = render([problem({ path: ["db", "port"], remote: "staging" })], { remotes: ["staging"], diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index abf3e94d2f..c53a0c7b93 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -32,6 +32,7 @@ import { missingProjectConfigMessageEffect, relativeConfigPath, } from "../../../command-internal/workdir-project.ts"; +import { cliRemoteProjectIdEnvName } from "../../../config/cli-config-keys.ts"; import { CLI_CONFIG_OPTIONAL_ENV_PATHS } from "../../../config/cli-config-key-annotations.ts"; import { describeCliConfigOrigin, @@ -185,10 +186,14 @@ function locateProblem( } const segments = item.path; const target = KEY_TARGET_CODES.has(item.code) ? "key" : "value"; - const attempts = [ - ...(item.remote === undefined ? [] : [["remotes", item.remote, ...segments]]), - segments, - ].map((path) => locateCliConfigKey(source, format, path, target)); + const remotePath = item.remote === undefined ? undefined : ["remotes", item.remote, ...segments]; + const candidates = + remotePath === undefined + ? [segments] + : item.code === "remote_project_id" + ? [remotePath] + : [remotePath, segments]; + const attempts = candidates.map((path) => locateCliConfigKey(source, format, path, target)); return ( attempts.find((found) => found?.exact === true) ?? attempts.find((found) => found !== undefined) ); @@ -376,9 +381,9 @@ export const configValidate = Effect.fn("config.validate")(function* () { format: FileFormat, stoppedAt: StopStage, problems: ReadonlyArray, - options: { readonly readSource: boolean } = { readSource: true }, + options: { readonly readSource: boolean; readonly source?: string } = { readSource: true }, ) { - const source = options.readSource ? yield* readSource(file) : ""; + const source = options.source ?? (options.readSource ? yield* readSource(file) : ""); const display = relativeConfigPath(workdir, file); return { file: display, @@ -431,13 +436,19 @@ export const configValidate = Effect.fn("config.validate")(function* () { if (error instanceof CliConfigSchemaVersionError) { const source = yield* readSource(error.path); return yield* emit( - yield* stopReport(error.path, error.format, "schema_version", [ - problem("schema_version", { - reason: error.message, - location: locateSchemaDeclaration(source, reportFormat(error.path, error.format)), - showValue: true, - }), - ]), + yield* stopReport( + error.path, + error.format, + "schema_version", + [ + problem("schema_version", { + reason: error.message, + location: locateSchemaDeclaration(source, reportFormat(error.path, error.format)), + showValue: true, + }), + ], + { readSource: true, source }, + ), ); } if (error instanceof CliConfigParseError) { @@ -519,7 +530,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { case "CliConfigValueError": return failed([invalidValueProblem(error, file)]); case "CliConfigRemovedEnvError": - return failed([problem("removed_env", { reason: error.message })]); + return failed([problem("removed_env", { reason: error.message })], "removed_env"); case "CliConfigLoadError": return failed([envLoadProblem(error)], "env_file"); default: @@ -580,24 +591,13 @@ export const configValidate = Effect.fn("config.validate")(function* () { return yield* emit(report); } - const failedRemotes = new Map( - (base.resolved?.remoteFailures ?? []).map((failure) => [failure.remote, failure]), - ); - const skippedRemotes = remoteNames - .filter((name) => failedRemotes.has(name)) - .map((name) => ({ name, reason: "invalid project_id" })); - const checkedRemotes = remoteNames.filter((name) => !failedRemotes.has(name)); - const remotePasses = yield* Effect.forEach(checkedRemotes, (name) => pass(name), { - concurrency: 1, - }).pipe(Effect.withSpan("config.validate.remotes")); - const passes = new Map([ - [undefined, base], - ...checkedRemotes.map((name, index): [string, PassResult] => [name, remotePasses[index]!]), - ]); - const references = envReferences(parsed.rawDocument); - const unresolved = yield* Effect.gen(function* () { - if (references.length === 0) return [] as ReadonlyArray; + const { unresolved, unresolvedProjectIdRemotes } = yield* Effect.gen(function* () { + const none = { + unresolved: [] as ReadonlyArray, + unresolvedProjectIdRemotes: new Set(), + }; + if (references.length === 0) return none; const resolved: ResolvedCliConfig | undefined = base.resolved ?? (yield* configValues @@ -608,13 +608,53 @@ export const configValidate = Effect.fn("config.validate")(function* () { validation: true, }) .pipe(Effect.option, Effect.map(Option.getOrUndefined))); - if (resolved === undefined) return []; + if (resolved === undefined) return none; + const projectIdRemotes = references.flatMap((reference) => + reference.remote !== undefined && reference.path.join(".") === "project_id" + ? [reference.remote] + : [], + ); const values = yield* resolved - .envValues(references.map((reference) => reference.name)) + .envValues([ + ...references.map((reference) => reference.name), + ...projectIdRemotes.map(cliRemoteProjectIdEnvName), + ]) .pipe(Effect.orElseSucceed((): Record => ({}))); - return references.filter((reference) => values[reference.name] === undefined); + const unresolvedRefs = references.filter((reference) => values[reference.name] === undefined); + return { + unresolved: unresolvedRefs, + unresolvedProjectIdRemotes: new Set( + unresolvedRefs.flatMap((reference) => + reference.remote !== undefined && + reference.path.join(".") === "project_id" && + values[cliRemoteProjectIdEnvName(reference.remote)] === undefined + ? [reference.remote] + : [], + ), + ), + }; }).pipe(Effect.withSpan("config.validate.env")); + const failedRemotes = new Map( + (base.resolved?.remoteFailures ?? []).map((failure) => [failure.remote, failure]), + ); + const skippedRemotes = remoteNames + .filter((name) => failedRemotes.has(name)) + .map((name) => ({ + name, + reason: unresolvedProjectIdRemotes.has(name) + ? "project_id is an unset env() reference" + : "invalid project_id", + })); + const checkedRemotes = remoteNames.filter((name) => !failedRemotes.has(name)); + const remotePasses = yield* Effect.forEach(checkedRemotes, (name) => pass(name), { + concurrency: 1, + }).pipe(Effect.withSpan("config.validate.remotes")); + const passes = new Map([ + [undefined, base], + ...checkedRemotes.map((name, index): [string, PassResult] => [name, remotePasses[index]!]), + ]); + const remoteWrites = (remote: string, path: ReadonlyArray): boolean => isDocumentRecord(rawRemotes) && getDocumentValue(rawRemotes[remote], path) !== undefined; const remoteOverrides = (remote: string, path: ReadonlyArray): boolean => @@ -638,7 +678,11 @@ export const configValidate = Effect.fn("config.validate")(function* () { ...notices, ...base.problems, ...(base.resolved?.remoteFailures ?? []) - .filter((failure) => remoteNames.includes(failure.remote)) + .filter( + (failure) => + remoteNames.includes(failure.remote) && + !unresolvedProjectIdRemotes.has(failure.remote), + ) .map(remoteFailureProblem), ...remotePasses.flatMap((item) => item.problems), ], @@ -664,10 +708,17 @@ export const configValidate = Effect.fn("config.validate")(function* () { showValue: true, ...(remote === undefined ? {} : { remote }), }); + const isOverridden = (reference: EnvReference, context: string | undefined): boolean => { + const origin = passes + .get(context) + ?.resolved?.materialized.originAt(displayPath(reference.path)); + return origin?.tier === "shell" || origin?.tier === "projectEnv" || origin?.tier === "flag"; + }; const envWarnings = unresolved.flatMap((reference) => { const warns = (context: string | undefined) => - blamed.some((item) => item.reference === reference && item.remote === context) || - isRelevantIn(reference, context); + !isOverridden(reference, context) && + (blamed.some((item) => item.reference === reference && item.remote === context) || + isRelevantIn(reference, context)); if (reference.remote !== undefined) { return warns(reference.remote) ? [envWarning(reference, reference.remote)] : []; } diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 72ea9c6520..3cbbdaf5cb 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -740,6 +740,113 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("skips a remote whose project_id is an unset env() reference and warns instead", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[remotes.prod]\nproject_id = "env(UNSET_REF)"\n'), + ); + + expect(result.exitCode).toBeUndefined(); + expect(result.stdout).toContain("⚠ project_id UNSET_REF is not set"); + expect(result.stdout).toContain("[remote: prod]"); + expect(result.stdout).toContain( + "Skipped remotes: prod (project_id is an unset env() reference)", + ); + expect(result.stdout).not.toContain("✗"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("still reports a malformed literal remote project_id", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[remotes.prod]\nproject_id = "nope"\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "remote_project_id", remote: "prod", line: 3 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("locates a remote without project_id inside that remote, not on the base key", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[remotes.prod]\n[remotes.prod.api]\nmax_rows = 1\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "remote_project_id", remote: "prod", line: 2 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("names the bigquery backend when a gcp key is missing", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[analytics]\nenabled = true\nbackend = "bigquery"\ngcp_jwt_path = "k.json"\n', + ), + ); + + expect(result.stdout).toContain( + "analytics.gcp_project_id required when analytics.backend = bigquery", + ); + expect(result.stdout).toContain('help: set gcp_project_id, or set backend = "postgres"'); + expect(result.stdout).toContain('help: set gcp_project_number, or set backend = "postgres"'); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("names auth.passkey.enabled as the switch for missing webauthn keys", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.passkey]\nenabled = true\n[auth.webauthn]\nrp_display_name = "x"\n', + ), + ); + + expect(result.stdout).toContain("help: set rp_id, or set auth.passkey.enabled = false"); + expect(result.stdout).not.toContain("set enabled = false"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "stays quiet about an unresolved reference when a SUPABASE_* variable supplies the value", + () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[db]\nport = "env(UNSET_DB_PORT)"\n'), + { env: { SUPABASE_DB_PORT: "5432" } }, + ); + + expect(result.stdout).toBe("✓ supabase/config.toml is valid\n"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports a removed env variable as an incomplete run", () => + Effect.gen(function* () { + const result = yield* validate(tomlFile('project_id = "demo"\n'), { + format: "json", + env: { SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION: "15" }, + }); + + expect(payloadOf(result)).toMatchObject({ + valid: false, + complete: false, + stopped_at: "removed_env", + }); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "removed_env" }, + ]); + + const text = yield* validate(tomlFile('project_id = "demo"\n'), { + env: { SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION: "15" }, + }); + expect(text.stdout).toContain("Stopped after resolving the environment variables."); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("locates keys in a TOML file that starts with a byte order mark", () => Effect.gen(function* () { const result = yield* validate( @@ -841,6 +948,38 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("masks the nested values of an inline table at a located value", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile(`project_id = "demo"\n[api]\nmax_rows = { secret = "${SECRET}" }\n`), + ); + + expect(result.stdout).toContain("api.max_rows"); + leaks(result); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("redacts credentials and query strings of a public URL value", () => + Effect.gen(function* () { + const url = "https://user:pass-secret@host.test/x?token=tok-secret"; + const context = yield* validate( + tomlFile(`project_id = "demo"\n[auth]\nsite_url = "${url}"\nbogus = 1\n`), + ); + expect(context.stdout).toContain('site_url = "https://****@host.test/x?****"'); + expect(context.stdout).not.toContain("pass-secret"); + expect(context.stdout).not.toContain("tok-secret"); + + const own = yield* validate( + tomlFile( + 'project_id = "demo"\n[studio]\napi_url = "http://u:pass-secret@[bad/x?token=tok-secret"\n', + ), + ); + expect(own.stdout).toContain("studio.api_url"); + expect(own.stdout).not.toContain("pass-secret"); + expect(own.stdout).not.toContain("tok-secret"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("masks db.root_key and a hook secret in JSON mode and in JSONC frames", () => Effect.gen(function* () { const result = yield* validate({ diff --git a/apps/cli/src/config/cli-config-remote.ts b/apps/cli/src/config/cli-config-remote.ts index 42d83a3bce..60d2f246f3 100644 --- a/apps/cli/src/config/cli-config-remote.ts +++ b/apps/cli/src/config/cli-config-remote.ts @@ -43,7 +43,7 @@ export interface CliConfigRemoteFailure { readonly message: string; } -/** Every remote with a duplicate or malformed `project_id`, one failure per remote,. */ +/** Every remote with a duplicate or malformed `project_id`, one failure per remote. */ export const cliConfigRemoteFailures = ( remotes: Readonly>, lookup: EnvLookup, From 03d66422663935f96ce31c5c8a8f43dbca2ebbec Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 10 Nov 2026 16:01:33 +0000 Subject: [PATCH 04/12] fix(cli): print config values in validate only when provably safe - Show a value only when it is the sole value on its line and a plain single-line scalar; mask arrays, tables, escaped and multi-line strings, and treat an inline table holding a triple-quoted string as one span. - Give the unrecognised schema warning a value-free reason. - Report an invalid env-sourced remote project_id against its variable. - Decode escaped inline-table keys when locating a path. - Stop with an explicit schema_limit stage, keeping the issues found, when schema pruning does not converge. - Give the collector's fallback issue value-free help. --- apps/cli/docs/supabase/config/validate.md | 2 +- .../command-internal/local-config-values.ts | 9 +- .../commands/config/validate/SIDE_EFFECTS.md | 4 +- .../config/validate/validate.format.ts | 120 +++++++++--------- .../validate/validate.format.unit.test.ts | 23 +++- .../config/validate/validate.handler.ts | 36 ++++-- .../validate/validate.integration.test.ts | 103 +++++++++++++++ .../validate.prune-limit.integration.test.ts | 67 ++++++++++ apps/cli/src/config/cli-config-remote.ts | 9 ++ apps/cli/src/config/cli-config-validation.ts | 3 + .../cli/src/config/cli-config-values.layer.ts | 26 ++-- .../src/config/cli-config-values.service.ts | 2 + packages/config/src/config-locate.ts | 31 ++++- .../config/src/config-locate.unit.test.ts | 17 +++ 14 files changed, 365 insertions(+), 87 deletions(-) create mode 100644 apps/cli/src/commands/config/validate/validate.prune-limit.integration.test.ts diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md index 358b62c809..698ad92648 100644 --- a/apps/cli/docs/supabase/config/validate.md +++ b/apps/cli/docs/supabase/config/validate.md @@ -12,7 +12,7 @@ Checks the project config file (`supabase/config.toml`, `config.json` or `config 6. The validations `supabase start` applies, such as a missing provider secret or an unreadable `content_path` file. 7. Every `[remotes.]` block, merged over the base config. Its problems carry the remote name. -Stages 1 to 4 stop the run when they fail, because later checks cannot be computed; the report says where it stopped (`complete: false`, `stopped_at` in JSON). Everything from stage 5 on is reported together. +Stages 1 to 4 stop the run when they fail, because later checks cannot be computed; the report says where it stopped (`complete: false`, `stopped_at` in JSON). A schema check that keeps finding dependent failures stops at `schema_limit` with the failures found so far. A remote `project_id` set by a `SUPABASE_REMOTES_*` variable is reported against that variable, not the file. Code frames print a value only when it is the sole plain scalar on its line; everything else is masked as `****`. Everything from stage 5 on is reported together. ## Secrets diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index e7d9eeb3b9..9c77f8b91f 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -871,7 +871,14 @@ export const collectLocalConfigIssues = Effect.fn("LocalConfig.collectIssues")(f ).pipe( Effect.catch((error: { readonly message: string }) => Effect.sync(() => { - issues.push(invalidIssue("", error.message, "could not be checked")); + issues.push( + invalidIssue( + "", + error.message, + "could not be checked", + "check the files and settings this config section reads", + ), + ); }), ), ); diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index 8b99fcf657..ec1f03e995 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -49,13 +49,13 @@ Telemetry records a run that reports problems as a successful command, because t ## Run order and stops -Each stage reports every problem it finds; a run stops after a stage only when the later stages cannot be computed: `syntax`, `schema_version`, `file_conflict`, `env_file`, `removed_env`. Remote blocks, unknown keys, removed keys, type and value errors and the semantic checks `supabase start` applies are all reported in one pass. JSON output sets `complete: false` and `stopped_at` when a run stopped early. +Each stage reports every problem it finds; a run stops after a stage only when the later stages cannot be computed: `syntax`, `schema_version`, `file_conflict`, `env_file`, `removed_env`, `schema_limit` (dependent schema failures hit the check's round limit). Remote blocks, unknown keys, removed keys, type and value errors and the semantic checks `supabase start` applies are all reported in one pass. JSON output sets `complete: false` and `stopped_at` when a run stopped early. ## Output ### `--output-format text` -stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting, and then only a scalar or flat array is shown, with URL credentials and query strings redacted; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting and its value is the only value on that line and is a plain number, boolean or string without escapes or multi-line delimiters; arrays, tables and everything else on the line stay masked, and URL credentials and query strings are redacted in any shown string; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. ### `--output-format json` diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index 1391743937..84164bc5bf 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -28,7 +28,13 @@ export const PLAIN_REPORT_STYLE: ReportStyle = { code: plain, }; -export type StopStage = "syntax" | "schema_version" | "file_conflict" | "env_file" | "removed_env"; +export type StopStage = + | "syntax" + | "schema_version" + | "file_conflict" + | "env_file" + | "removed_env" + | "schema_limit"; const STOP_LABELS: Readonly> = { syntax: "the syntax check", @@ -36,6 +42,7 @@ const STOP_LABELS: Readonly> = { file_conflict: "the config file check", env_file: "loading the env files", removed_env: "resolving the environment variables", + schema_limit: "the schema check, which hit its limit on dependent failures", }; const PAYLOAD_VERSION = 1; @@ -147,90 +154,78 @@ function urlSpans(text: string, from: number, to: number): ReadonlyArray { return spans; } -/** A scalar or a flat array of scalars; a table, or anything nested, may hold values the problem is not about. */ -function isShowable(lines: ReadonlyArray, location: CliConfigLocation): boolean { - const first = lines[location.line - 1] ?? ""; - const last = location.endLine ?? location.line; - const text = - last === location.line - ? first.slice(location.column - 1, (location.endColumn ?? first.length + 1) - 1) - : [ - first.slice(location.column - 1), - ...lines.slice(location.line, last - 1), - (lines[last - 1] ?? "").slice(0, (location.endColumn ?? 1) - 1), - ].join("\n"); - const head = text.trimStart()[0]; - if (head === "{") return false; - if (head !== "[") return true; - let quote: string | undefined; - for (let index = text.indexOf("[") + 1; index < text.length; index++) { - const character = text[index]; - if (quote !== undefined) { - if (character === "\\" && quote === '"') index++; - else if (character === quote) quote = undefined; - } else if (character === '"' || character === "'") quote = character; - else if (character === "{" || character === "[") return false; +const PLAIN_SCALAR = /^[^\s[\]{}"',#]+$/; + +/** A number or boolean, or a string with no escapes and no multi-line delimiter. */ +function isPlainScalar(text: string): boolean { + const quote = text[0]; + if (quote === '"' || quote === "'") { + return ( + text.length >= 2 && + text.endsWith(quote) && + !text.startsWith(quote.repeat(3)) && + !text.includes("\\") + ); } - return true; + return PLAIN_SCALAR.test(text); } -const isBefore = (line: number, column: number, otherLine: number, otherColumn: number) => - line < otherLine || (line === otherLine && column < otherColumn); - -const isWithin = (value: CliConfigLocation, shown: CliConfigLocation): boolean => - !isBefore(value.line, value.column, shown.line, shown.column) && - !isBefore( - shown.endLine ?? shown.line, - shown.endColumn ?? shown.column, - value.endLine ?? value.line, - value.endColumn ?? value.column, - ); - /** - * A source line with every value masked, whichever lines it spans, except the values inside - * `shown`. Lines of text that cannot be parsed are masked in full. + * A source line with every value masked, whichever lines it spans. A value prints only when + * `mayShow` and it is the only value touching a line, one plain scalar that starts and ends on it; + * URL credentials and query strings are redacted either way. Source that cannot be parsed is + * masked in full. */ function maskedAt( { lines, valuesByLine }: SourceView, lineNumber: number, - shown?: CliConfigLocation, + mayShow: (value: CliConfigLocation) => boolean = () => false, ): MaskedLine { const text = lines[lineNumber - 1] ?? ""; const indent = text.length - text.trimStart().length; if (text.trim() === "") return maskLine(text, []); if (valuesByLine === undefined) return maskLine(text, [{ column: indent + 1 }]); + const values = valuesByLine.get(lineNumber) ?? []; + const only = values.length === 1 ? values[0] : undefined; + const printable = + only !== undefined && + only.line === lineNumber && + only.endLine === lineNumber && + mayShow(only) && + isPlainScalar(text.slice(only.column - 1, (only.endColumn ?? 1) - 1)); + const redactions = urlSpans(text, 1, text.length + 1); + const spans: Array = []; - const redactions: Array = []; - for (const value of valuesByLine.get(lineNumber) ?? []) { + for (const value of values) { const startsHere = value.line === lineNumber; const endsHere = (value.endLine ?? value.line) === lineNumber; const column = startsHere ? value.column : indent + 1; const endColumn = endsHere ? value.endColumn : undefined; - if (shown !== undefined && isWithin(value, shown)) { - redactions.push(...urlSpans(text, column, endColumn ?? text.length + 1)); - continue; - } - if ( + const isEnvReference = startsHere && endsHere && - ENV_REFERENCE_VALUE.test(text.slice(column - 1, (endColumn ?? text.length + 1) - 1)) - ) { - continue; + ENV_REFERENCE_VALUE.test(text.slice(column - 1, (endColumn ?? text.length + 1) - 1)); + if (!printable && !isEnvReference) { + spans.push(endColumn === undefined ? { column } : { column, endColumn }); } - spans.push(endColumn === undefined ? { column } : { column, endColumn }); } - if (spans.length === 0) { + if (values.length === 0) { const pair = /^\s*(?:#|\/\/)/.test(text) ? KEY_VALUE.exec(text) : null; return maskLine(text, [ ...redactions, ...(pair?.[1] === undefined ? [] : [{ column: pair[1].length + 1 }]), ]); } - const lastEnd = Math.max(...spans.map((span) => span.endColumn ?? text.length + 1)) - 1; + const lastEnd = + Math.max( + ...values.map((value) => + value.endLine === lineNumber ? (value.endColumn ?? 1) : text.length + 1, + ), + ) - 1; const comment = TRAILING_COMMENT.exec(text.slice(lastEnd)); if (comment !== null) { - spans.push({ column: lastEnd + comment[0].length - comment[1]!.length + 1 }); + spans.push({ column: lastEnd + comment[0].length - (comment[1] ?? "").length + 1 }); } return maskLine(text, [...spans, ...redactions]); } @@ -292,10 +287,7 @@ function frameLines( const out: Array = []; if (location.line > 1) { - const previous = lines[location.line - 2] ?? ""; - const before = publicLines.has(location.line - 1) - ? maskLine(previous, urlSpans(previous, 1, previous.length + 1)).text - : maskedAt(view, location.line - 1).text; + const before = maskedAt(view, location.line - 1, () => publicLines.has(location.line - 1)).text; const shown = display(before).trimEnd(); const clipped = shown.length > MAX_FRAME_WIDTH ? `${shown.slice(0, MAX_FRAME_WIDTH)}…` : shown; out.push( @@ -303,9 +295,13 @@ function frameLines( ); } - const shown = - problem.showValue && location.exact && isShowable(lines, location) ? location : undefined; - const current = maskedAt(view, location.line, shown); + const mayShow = (value: CliConfigLocation) => + problem.showValue && + location.exact && + value.line === location.line && + value.column === location.column && + value.endColumn === location.endColumn; + const current = maskedAt(view, location.line, mayShow); const start = current.map(location.column - 1); const singleLine = location.endLine === undefined || location.endLine === location.line; const end = @@ -327,7 +323,7 @@ function frameLines( ` ${style.dim(gutter(""))} ${" ".repeat(windowed.caretOffset)}${style.red("^".repeat(caretLength))}`, ); for (let line = location.line + 1; line <= lastLine; line++) { - const text = display(maskedAt(view, line, shown).text).trimEnd(); + const text = display(maskedAt(view, line).text).trimEnd(); const clipped = text.length > MAX_FRAME_WIDTH ? `${text.slice(0, MAX_FRAME_WIDTH)}…` : text; out.push(` ${style.red(">")} ${style.dim(gutter(String(line)))} ${clipped}`); } diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index b8dcec6638..5e5c777a74 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -79,7 +79,7 @@ describe("validateReport", () => { expect(text).not.toContain("hunter2"); }); - it("shows the located value only when the problem allows it, still masking siblings on the line", () => { + it("shows the located value only when it is the only value on its line", () => { const text = render([ problem({ path: ["api", "cfg", "n"], @@ -88,10 +88,25 @@ describe("validateReport", () => { }), ]); - expect(text).toContain('cfg = { token = ****, n = "bad" }'); + expect(text).toContain("cfg = { token = ****, n = **** }"); expect(text).not.toContain("hunter2"); }); + it("shows a located scalar that is alone on its line", () => { + const text = render( + [ + problem({ + path: ["api", "max_rows"], + showValue: true, + location: { line: 2, column: 12, endColumn: 18, exact: true }, + }), + ], + { source: '[api]\nmax_rows = "lots"' }, + ); + + expect(text).toContain('max_rows = "lots"'); + }); + it("shows a known non-secret key's value on the context line", () => { const text = render([ problem({ @@ -224,7 +239,7 @@ describe("validateReport", () => { expect(text).not.toContain("hunter2"); }); - it("shows a flat array of scalars", () => { + it("masks a flat array even at a shown location", () => { const source = '[api]\nschemas = ["a", "b"]'; const text = render( [ @@ -237,7 +252,7 @@ describe("validateReport", () => { { source }, ); - expect(text).toContain('schemas = ["a", "b"]'); + expect(text).toContain("schemas = ****"); }); const URL_SOURCE = [ diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index c53a0c7b93..6ba273730d 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -327,7 +327,10 @@ function remotesShapeProblems(rawRemotes: unknown): ReadonlyArray ); } -function remoteFailureProblem(failure: { remote: string; message: string }): ConfigProblem { +function remoteFailureProblem( + failure: { remote: string; message: string; envName?: string }, + file: string, +): ConfigProblem { const duplicate = /^duplicate project_id for \[remotes\.[^\]]+\] and \[remotes\.([^\]]+)\]$/.exec( failure.message, ); @@ -338,11 +341,18 @@ function remoteFailureProblem(failure: { remote: string; message: string }): Con duplicate?.[1] === undefined ? "not a project ref" : `same project_id as remotes.${duplicate[1]}`, - help: - duplicate?.[1] === undefined - ? "use the 20-character ref from your project's dashboard URL" - : "give each remote its own project_id", - showValue: true, + ...(failure.envName === undefined + ? { + help: + duplicate?.[1] === undefined + ? "use the 20-character ref from your project's dashboard URL" + : "give each remote its own project_id", + showValue: true, + } + : { + external: true as const, + help: `${failure.envName} sets this project_id, not ${file}; fix or unset it there`, + }), }); } @@ -494,7 +504,8 @@ export const configValidate = Effect.fn("config.validate")(function* () { problem("unrecognized_schema", { severity: "warning", subject: file, - reason: `${declaration.ref} is not a Supabase config schema, so the file is read as schema v1`, + reason: + "the schema reference is not a Supabase config schema, so the file is read as schema v1", help: "set it to https://supabase.com/schemas/config/v1.json", location: locateSchemaDeclaration(parsed.rawText, format), showValue: true, @@ -538,6 +549,13 @@ export const configValidate = Effect.fn("config.validate")(function* () { } } const resolved = result.success; + if (resolved.pruneLimitReached) { + return { + problems: withRemote(schemaProblems(resolved.schemaFailures), remote), + resolved, + stop: "schema_limit", + } satisfies PassResult; + } const semantic = yield* collectLocalConfigIssues( resolved.materialized.config, workdir, @@ -683,7 +701,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { remoteNames.includes(failure.remote) && !unresolvedProjectIdRemotes.has(failure.remote), ) - .map(remoteFailureProblem), + .map((failure) => remoteFailureProblem(failure, file)), ...remotePasses.flatMap((item) => item.problems), ], remoteWrites, @@ -732,12 +750,14 @@ export const configValidate = Effect.fn("config.validate")(function* () { location: locateProblem(item, parsed.rawText, parsed.format), })); + const remoteStop = remotePasses.find((item) => item.stop !== undefined)?.stop; return yield* emit({ file, format, source: parsed.rawText, remotes: checkedRemotes, skippedRemotes, + ...(remoteStop === undefined ? {} : { stoppedAt: remoteStop }), problems: located, }); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 3cbbdaf5cb..7f98ba8612 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -769,6 +769,33 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("reports an invalid env-sourced remote project_id as external, not at the file", () => + Effect.gen(function* () { + const files = tomlFile( + 'project_id = "demo"\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n', + ); + const json = yield* validate(files, { + format: "json", + env: { SUPABASE_REMOTES_PROD_PROJECT_ID: "nope" }, + }); + const text = yield* validate(files, { + env: { SUPABASE_REMOTES_PROD_PROJECT_ID: "nope" }, + }); + + const errors = Reflect.get(payloadOf(json) ?? {}, "errors"); + expect(errors).toMatchObject([ + { + code: "remote_project_id", + remote: "prod", + help: expect.stringContaining("SUPABASE_REMOTES_PROD_PROJECT_ID"), + }, + ]); + expect(errors).not.toMatchObject([{ line: expect.anything() }]); + expect(text.stdout).toContain("SUPABASE_REMOTES_PROD_PROJECT_ID"); + expect(text.stdout).not.toMatch(/config\.toml:\d/); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("locates a remote without project_id inside that remote, not on the base key", () => Effect.gen(function* () { const result = yield* validate( @@ -980,6 +1007,82 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + const ADVERSARIAL: ReadonlyArray>]> = [ + [ + "multi-line secret with comment-looking and base64 lines", + tomlFile( + `project_id = "demo"\n[auth.hook.send_email]\nenabled = true\nuri = "https://x.test"\nsecrets = """\n# ${SECRET}\nc2VjcmV0cGFzc3dvcmQ=="""\nbogus = 1\n`, + ), + ], + [ + "sibling of an env() reference in an array", + tomlFile( + `project_id = "demo"\n[api]\nschemas = ["env(UNSET_SCHEMA)", "${SECRET}"]\nbogus = 1\n`, + ), + ], + [ + "nested inline table at a public key", + tomlFile(`project_id = "demo"\n[api]\nmax_rows = { secret = "${SECRET}" }\nbogus = 1\n`), + ], + [ + "nested table inside an array context line", + tomlFile(`project_id = "demo"\n[api]\nschemas = [{ secret = "${SECRET}" }]\nbogus = 1\n`), + ], + [ + "triple-quoted string inside an inline table", + tomlFile( + `project_id = "demo"\nauth = { jwt_secret = """foo", ${SECRET} = "x", tail = "bar""" }\nbogus = 1\n`, + ), + ], + [ + "credential URL on the context line", + tomlFile( + `project_id = "demo"\n[auth]\nsite_url = "https://user:pw-secret@host.test/x?token=tok-secret"\nbogus = 1\n`, + ), + ], + [ + "credential URL as the problem value", + tomlFile( + `project_id = "demo"\n[studio]\napi_url = "http://u:pw-secret@[bad/x?token=tok-secret"\n`, + ), + ], + [ + "escaped JSON URL on the context line", + { + "supabase/config.jsonc": `{\n "project_id": "demo",\n "auth": {\n "site_url": "https:\\/\\/user:pw-secret@host.test/x?token=tok-secret",\n "bogus": 1\n }\n}\n`, + }, + ], + [ + "line that ends a multi-line JSON value and starts a public property", + { + "supabase/config.jsonc": `{\n "project_id": "demo",\n "api": {\n "schemas": [\n "a",\n "${SECRET}"], "max_rows": 1000,\n "prot": 1\n }\n}\n`, + }, + ], + [ + "schema reference carrying credentials", + tomlFile( + `#:schema https://user:pw-secret@host.test/s.json?token=tok-secret\nproject_id = "demo"\n`, + ), + ], + ]; + + for (const [name, files] of ADVERSARIAL) { + it.effect(`never prints a marker for: ${name}`, () => + Effect.gen(function* () { + const text = yield* validate(files); + const json = yield* validate(files, { format: "json" }); + + expect(text.stdout).toMatch(/[✗⚠]/); + for (const result of [text, json]) { + const printed = reportedTexts(result); + for (const marker of ["hunter2", "pw-secret", "tok-secret", "c2VjcmV0"]) { + expect(printed).not.toContain(marker); + } + } + }).pipe(Effect.provide(BunServices.layer)), + ); + } + it.effect("masks db.root_key and a hook secret in JSON mode and in JSONC frames", () => Effect.gen(function* () { const result = yield* validate({ diff --git a/apps/cli/src/commands/config/validate/validate.prune-limit.integration.test.ts b/apps/cli/src/commands/config/validate/validate.prune-limit.integration.test.ts new file mode 100644 index 0000000000..55e741c9e4 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.prune-limit.integration.test.ts @@ -0,0 +1,67 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { vi } from "vitest"; + +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, + useTempWorkdir, +} from "../../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; +import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { configValidate } from "./validate.handler.ts"; + +// oxlint-disable-next-line effecttsgo/async-function -- vi.mock factories are promise-based +vi.mock("../../../config/cli-config-validation.ts", async (importOriginal) => ({ + ...(await importOriginal()), + MAX_PRUNE_ROUNDS: 0, +})); + +const tempRoot = useTempWorkdir("supabase-config-validate-prune-"); + +const validate = Effect.fnUntraced(function* (text: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(tempRoot.current, "supabase", "config.toml"); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, text); + const out = mockOutput({ format: "json" }); + const processControl = mockProcessControl(); + yield* configValidate().pipe( + Effect.provide( + Layer.mergeAll( + out.layer, + mockTelemetryStateTracked().layer, + processControl.layer, + mockCommandSettings({ workdir: tempRoot.current, explicitWorkdir: true }), + Layer.succeed(OutputFlag, Option.none()), + configValuesLayer({}), + BunServices.layer, + ), + ), + ); + return { + payload: out.messages.find((message) => message.type === "success")?.data, + exitCode: processControl.exitCode, + }; +}); + +describe("config validate when the schema prune limit is reached", () => { + it.effect("keeps the schema issues it found and reports an incomplete run", () => + Effect.gen(function* () { + const result = yield* validate( + 'project_id = "demo"\n[storage.buckets.images]\npublic = "yes"\n', + ); + + expect(result.exitCode).toBe(1); + expect(result.payload).toMatchObject({ + valid: false, + complete: false, + stopped_at: "schema_limit", + errors: [{ code: "invalid_type", key: "storage.buckets.images.public" }], + }); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/config/cli-config-remote.ts b/apps/cli/src/config/cli-config-remote.ts index 60d2f246f3..cb7851676b 100644 --- a/apps/cli/src/config/cli-config-remote.ts +++ b/apps/cli/src/config/cli-config-remote.ts @@ -41,8 +41,15 @@ export const selectCliConfigRemote = ( export interface CliConfigRemoteFailure { readonly remote: string; readonly message: string; + /** The env variable that supplied the rejected `project_id`, when it overrode the file. */ + readonly envName?: string; } +const overrideField = (name: string, lookup: EnvLookup) => { + const envName = cliRemoteProjectIdEnvName(name); + return lookup(envName) === undefined ? {} : { envName }; +}; + /** Every remote with a duplicate or malformed `project_id`, one failure per remote. */ export const cliConfigRemoteFailures = ( remotes: Readonly>, @@ -58,6 +65,7 @@ export const cliConfigRemoteFailures = ( failures.set(name, { remote: name, message: `duplicate project_id for [remotes.${name}] and [remotes.${prior}]`, + ...overrideField(name, lookup), }); continue; } @@ -73,6 +81,7 @@ export const cliConfigRemoteFailures = ( failures.set(name, { remote: name, message: `Invalid config for remotes.${name}.project_id. Must be like: abcdefghijklmnopqrst`, + ...overrideField(name, lookup), }); } } diff --git a/apps/cli/src/config/cli-config-validation.ts b/apps/cli/src/config/cli-config-validation.ts index 8df3b7c500..da025be691 100644 --- a/apps/cli/src/config/cli-config-validation.ts +++ b/apps/cli/src/config/cli-config-validation.ts @@ -13,3 +13,6 @@ export const decodeCliConfigDocumentForValidation = ( document: Record, options: CliConfigValidationOptions, ) => decodeCliConfigDocumentForValidationEffect(document, { ...options, cliCompat: true }); + +/** How many times a validation load deletes failing keys and decodes again before it gives up. */ +export const MAX_PRUNE_ROUNDS = 5; diff --git a/apps/cli/src/config/cli-config-values.layer.ts b/apps/cli/src/config/cli-config-values.layer.ts index b9bc047a09..57224b7039 100644 --- a/apps/cli/src/config/cli-config-values.layer.ts +++ b/apps/cli/src/config/cli-config-values.layer.ts @@ -41,6 +41,7 @@ import { sameDocumentValue, setDocumentValue, } from "./cli-config-document.ts"; +import { MAX_PRUNE_ROUNDS } from "./cli-config-validation.ts"; import { cliConfigFailureKey } from "./cli-config-failure-key.ts"; import { CliConfigFlagInputs, cliConfigFlagConflictError } from "./cli-config-flags.ts"; import { @@ -200,8 +201,6 @@ const withoutRemotes = (document: Record): Record, @@ -406,7 +405,10 @@ export const cliConfigValuesLayer = Layer.effect( : []; const [firstRemoteFailure] = remoteFailures; if (firstRemoteFailure !== undefined && !target.validation) { - return yield* new CliConfigLoadError(firstRemoteFailure); + return yield* new CliConfigLoadError({ + message: firstRemoteFailure.message, + remote: firstRemoteFailure.remote, + }); } const merged = @@ -580,12 +582,14 @@ export const cliConfigValuesLayer = Layer.effect( const mergedForDecode = merged ?? emptyMergedDocument(target.workdir, path.sep); const schemaFailures: Array = []; const pruned: Array> = []; + let pruneLimitReached = false; const decode = ( draft: Record, resolvedPaths: ReadonlyArray>, reportFailures: boolean, ) => Effect.gen(function* () { + let cleared = false; for (let round = 0; ; round++) { const result = yield* withPlatform( decodeMergedCliConfig(mergedForDecode, { @@ -598,13 +602,16 @@ export const cliConfigValuesLayer = Layer.effect( ).pipe(Effect.result); if (Result.isSuccess(result)) return result.success; const error = result.failure; - if ( - !target.validation || - !(error instanceof CliConfigParseError) || - round >= MAX_PRUNE_ROUNDS - ) { + if (!target.validation || !(error instanceof CliConfigParseError) || cleared) { return yield* error; } + if (round >= MAX_PRUNE_ROUNDS) { + if (reportFailures) schemaFailures.push(error); + pruneLimitReached = true; + cleared = true; + for (const key of Object.keys(draft)) delete draft[key]; + continue; + } const targets = flattenSchemaIssues(error.cause) .map((issue) => prunableSegments(draft, issue.path, issue.code === "missing_required"), @@ -686,6 +693,9 @@ export const cliConfigValuesLayer = Layer.effect( invalid, remoteFailures: target.validation ? remoteFailures : [], schemaFailures, + get pruneLimitReached() { + return pruneLimitReached; + }, pruned, familyNames, declares: (configPath) => configAt(configPath) !== undefined, diff --git a/apps/cli/src/config/cli-config-values.service.ts b/apps/cli/src/config/cli-config-values.service.ts index 3aaea0db55..d05f8d0623 100644 --- a/apps/cli/src/config/cli-config-values.service.ts +++ b/apps/cli/src/config/cli-config-values.service.ts @@ -91,6 +91,8 @@ export interface ResolvedCliConfig { readonly remoteFailures: ReadonlyArray; /** Schema decode failures a `validation` load pruned past, in order; empty otherwise. */ readonly schemaFailures: ReadonlyArray; + /** Whether a `validation` load stopped deleting failing keys at its round limit, so the config is partial. */ + readonly pruneLimitReached: boolean; /** The key paths a `validation` load deleted from the document so decoding could continue; empty otherwise. */ readonly pruned: ReadonlyArray>; /** The entry names of a family: those the registry declares plus those in the merged document. */ diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index c241c02ad8..1148da59ab 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -120,6 +120,31 @@ interface InlineEntry { const isBareKeyCharacter = (character: string | undefined) => character !== undefined && /[A-Za-z0-9_-]/.test(character); +const BASIC_ESCAPES: Readonly> = { + b: "\b", + t: "\t", + n: "\n", + f: "\f", + r: "\r", + e: "\u001b", + '"': '"', + "\\": "\\", +}; + +function decodeBasicKey(raw: string): string { + return raw.replace( + /\\(?:u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|(.))/g, + (match, short, long, simple) => { + const hex = short ?? long; + if (hex !== undefined) { + const code = Number.parseInt(hex, 16); + return code <= 0x10ffff ? String.fromCodePoint(code) : match; + } + return BASIC_ESCAPES[simple] ?? match; + }, + ); +} + function scanKeyPart( source: string, start: number, @@ -131,7 +156,9 @@ function scanKeyPart( while (cursor < limit && source[cursor] !== opener) { cursor += opener === '"' && source[cursor] === "\\" ? 2 : 1; } - return cursor >= limit ? undefined : { text: source.slice(start + 1, cursor), end: cursor + 1 }; + if (cursor >= limit) return undefined; + const raw = source.slice(start + 1, cursor); + return { text: opener === '"' ? decodeBasicKey(raw) : raw, end: cursor + 1 }; } let cursor = start; while (cursor < limit && isBareKeyCharacter(source[cursor])) cursor += 1; @@ -381,6 +408,8 @@ export function cliConfigLineKeys( function tomlValueSpans(source: string, span: Span): ReadonlyArray { if (source[span.start] !== "{") return [span]; + const text = source.slice(span.start, span.end); + if (text.includes('"""') || text.includes("'''")) return [span]; const entries = inlineTableEntries(source, span); return entries === undefined ? [span] diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index 61e1dd9f33..377335828c 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -251,8 +251,25 @@ describe("cliConfigValueSpans", () => { expect(textOf(source, cliConfigValueSpans(source, "json"))).toEqual(['"x"', "2", "true"]); }); + test("covers an inline table holding a triple-quoted string as one span", () => { + const source = 'auth = { jwt_secret = """foo", hunter2 = "x", tail = "bar""" }\n'; + expect(textOf(source, cliConfigValueSpans(source, "toml"))).toEqual([ + '{ jwt_secret = """foo", hunter2 = "x", tail = "bar""" }', + ]); + }); + test("is undefined when the text does not parse", () => { expect(cliConfigValueSpans('a = """\nx', "toml")).toBeUndefined(); expect(cliConfigValueSpans('{ "a": ', "json")).toBeUndefined(); }); }); + +describe("locateCliConfigKey inline-table keys", () => { + test("matches a quoted key whose escapes decode to the path segment", () => { + const source = 'api = { "po\\u0072t" = "bad" }\n'; + const location = locateCliConfigKey(source, "toml", ["api", "port"], "value"); + + expect(location?.exact).toBe(true); + expect(source.slice((location?.column ?? 0) - 1, (location?.endColumn ?? 0) - 1)).toBe('"bad"'); + }); +}); From f459bb8f61fd1f2e565239a1941034660679b7c9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 10 Nov 2026 16:46:16 +0000 Subject: [PATCH 05/12] fix(cli): parse URLs before showing them in config validate - Print a URL value only after parsing it, with userinfo, query and fragment redacted; mask the whole value when it does not parse or carries @, ? or # without a scheme. - Treat every flag, shell or project env failure, including a failed decryption, as external so an unresolved file reference cannot hide it. - Keep segment boundaries in the public-value check, the gate lookup and dynamic function and provider names. - Use remote-scoped headers in rename hints and classify codec failures as invalid_type, invalid_enum or invalid_value. - Replace the module-level scan cache with a caller-owned document scan. - Correct which file the docs say is shadowed. --- apps/cli/docs/supabase/config/validate.md | 2 +- .../src/command-internal/config-validate.ts | 21 ++-- .../config-validate.unit.test.ts | 19 +++ .../commands/config/validate/SIDE_EFFECTS.md | 2 +- .../config/validate/validate.format.ts | 65 ++++++++--- .../validate/validate.format.unit.test.ts | 25 +++- .../config/validate/validate.handler.ts | 67 ++++++----- .../validate/validate.integration.test.ts | 86 ++++++++++++++ .../config/validate/validate.model.ts | 19 +-- .../validate/validate.model.unit.test.ts | 19 ++- apps/cli/src/config/cli-config-key.ts | 16 ++- apps/cli/src/config/cli-config.errors.ts | 2 + packages/config/AGENTS.md | 2 +- packages/config/src/config-locate.ts | 110 +++++++++--------- .../config/src/config-locate.unit.test.ts | 13 ++- .../config/src/entrypoint-purity.unit.test.ts | 4 +- packages/config/src/internal.ts | 5 +- 17 files changed, 340 insertions(+), 137 deletions(-) diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md index 698ad92648..b28a0dafc9 100644 --- a/apps/cli/docs/supabase/config/validate.md +++ b/apps/cli/docs/supabase/config/validate.md @@ -6,7 +6,7 @@ Checks the project config file (`supabase/config.toml`, `config.json` or `config 1. The file parses (TOML or JSONC). 2. The schema version is one this CLI reads. -3. Only one config file applies: `config.json` and `config.jsonc` together is an error, and a JSON file next to `config.toml` is reported as shadowed. +3. Only one config file applies: `config.json` and `config.jsonc` together is an error, and `config.toml` next to a JSON file is reported as shadowed (the JSON file is the one used). 4. The env files load and no removed `SUPABASE_*` variable is set, so `env(NAME)` references and overrides resolve. 5. Removed keys, unknown keys (with a `did you mean` suggestion when one is close), values of the wrong type, enum or range, and missing required keys. 6. The validations `supabase start` applies, such as a missing provider secret or an unreadable `content_path` file. diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index 7f2dba0968..320fa97b38 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -666,14 +666,15 @@ export function collectConfigValidationIssues( for (const slug of input.functionSlugs) { if (!FUNCTION_SLUG_PATTERN.test(slug)) { - report( - invalidIssue( + report({ + ...invalidIssue( `functions.${slug}`, `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${FUNCTION_SLUG_PATTERN.source})`, "invalid function name", "start with a letter; use letters, digits, underscores and hyphens only", ), - ); + segments: ["functions", slug], + }); } } @@ -1013,24 +1014,26 @@ export function collectAuthExternalProviderIssues( for (const [name, provider] of Object.entries(resolved)) { if (!provider.enabled) continue; if (provider.clientId.length === 0) { - issues.push( - missingIssue( + issues.push({ + ...missingIssue( `auth.external.${name}.client_id`, `Missing required field in config: auth.external.${name}.client_id`, ), - ); + segments: ["auth", "external", name, "client_id"], + }); } if ( name !== "apple" && name !== "google" && (provider.secret === undefined || provider.secret.length === 0) ) { - issues.push( - missingIssue( + issues.push({ + ...missingIssue( `auth.external.${name}.secret`, `Missing required field in config: auth.external.${name}.secret`, ), - ); + segments: ["auth", "external", name, "secret"], + }); } } return issues; diff --git a/apps/cli/src/command-internal/config-validate.unit.test.ts b/apps/cli/src/command-internal/config-validate.unit.test.ts index 742413b680..ed82970d11 100644 --- a/apps/cli/src/command-internal/config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.unit.test.ts @@ -1415,6 +1415,25 @@ describe("collectConfigValidationIssues", () => { expect(issues.map((issue) => issue.path)).toEqual(["api.port", "studio.port", "auth.site_url"]); }); + it("keeps the segments of dynamic names that contain a dot", () => { + const [functionIssue] = collectConfigValidationIssues( + minimalInput({ functionSlugs: ["bad.name"] }), + ); + expect(functionIssue?.segments).toEqual(["functions", "bad.name"]); + + const [providerIssue] = collectAuthExternalProviderIssues({ + "my.idp": { + enabled: true, + clientId: "", + secret: "", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }); + expect(providerIssue?.segments).toEqual(["auth", "external", "my.idp", "client_id"]); + }); + it("reports nothing for a valid input", () => { expect(collectConfigValidationIssues(minimalInput())).toEqual([]); }); diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index ec1f03e995..371af90eee 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -55,7 +55,7 @@ Each stage reports every problem it finds; a run stops after a stage only when t ### `--output-format text` -stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting and its value is the only value on that line and is a plain number, boolean or string without escapes or multi-line delimiters; arrays, tables and everything else on the line stay masked, and URL credentials and query strings are redacted in any shown string; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting and its value is the only value on that line and is a plain number, boolean or string without escapes or multi-line delimiters; arrays, tables and everything else on the line stay masked, and a shown string that holds a URL is printed as `scheme://****@host/path?****#****` (credentials, query and fragment redacted) and one that looks like credentials or cannot be parsed is masked whole; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. ### `--output-format json` diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index 84164bc5bf..ed2a363884 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -1,9 +1,5 @@ import { escapeControlCharacters } from "../../../shared/output/escape-control-characters.ts"; -import { - cliConfigLineKeys, - cliConfigValueSpans, - type CliConfigLocation, -} from "@supabase/config/internal"; +import { scanCliConfigDocument, type CliConfigLocation } from "@supabase/config/internal"; import { displayPath, isPublicValuePath, type ConfigProblem } from "./validate.model.ts"; @@ -90,13 +86,14 @@ const display = (text: string): string => interface Span { readonly column: number; readonly endColumn?: number; + readonly replacement?: string; } const KEY_VALUE = /^(\s*(?:(?:#|\/\/)\s*)?(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+))*\s*[=:]\s*)(.*)$/; const ENV_REFERENCE_VALUE = /^["']env\([A-Za-z_][A-Za-z0-9_]*\)["']\s*,?\s*$/; -const URL_USERINFO = /[a-z][a-z0-9+.-]*:\/\/([^\s/?#"']*)@/gi; -const URL_QUERY = /[a-z][a-z0-9+.-]*:\/\/[^\s"'?#]*\?([^\s"'#]*)/gi; +const URL_USERINFO = /[a-z][a-z0-9+.-]*:\/\/([^\s/?#"]*)@/gi; +const URL_QUERY = /[a-z][a-z0-9+.-]*:\/\/[^\s"?#]*\?([^\s"#]*)/gi; const TRAILING_COMMENT = /^[\s,}\]]*(#|\/\/)/; interface MaskedLine { @@ -109,17 +106,18 @@ function maskLine(text: string, spans: ReadonlyArray): MaskedLine { .map((span) => ({ start: Math.min(span.column - 1, text.length), end: span.endColumn === undefined ? text.length : Math.min(span.endColumn - 1, text.length), + replacement: span.replacement ?? MASK, })) .sort((left, right) => left.start - right.start); let out = ""; let position = 0; - const applied: Array<{ start: number; end: number; outStart: number }> = []; + const applied: Array<{ start: number; end: number; outStart: number; length: number }> = []; for (const range of ranges) { if (range.end <= position) continue; const start = Math.max(range.start, position); out += text.slice(position, start); - applied.push({ start, end: range.end, outStart: out.length }); - out += MASK; + applied.push({ start, end: range.end, outStart: out.length, length: range.replacement.length }); + out += range.replacement; position = range.end; } out += text.slice(position); @@ -130,7 +128,7 @@ function maskLine(text: string, spans: ReadonlyArray): MaskedLine { for (const range of applied) { if (index < range.start) break; if (index < range.end) return range.outStart; - shift += MASK.length - (range.end - range.start); + shift += range.length - (range.end - range.start); } return index + shift; }, @@ -154,6 +152,24 @@ function urlSpans(text: string, from: number, to: number): ReadonlyArray { return spans; } +/** The scalar as it may print: URL credentials, query and fragment replaced; undefined when it must be masked whole. */ +function redactScalar(raw: string): string | undefined { + const quote = raw[0] === '"' || raw[0] === "'" ? raw[0] : ""; + const inner = quote === "" ? raw : raw.slice(1, -1); + if (inner.includes("://")) { + try { + const url = new URL(inner); + const userinfo = url.username !== "" || url.password !== "" ? `${MASK}@` : ""; + const search = url.search === "" ? "" : `?${MASK}`; + const hash = url.hash === "" ? "" : `#${MASK}`; + return `${quote}${url.protocol}//${userinfo}${url.host}${url.pathname}${search}${hash}${quote}`; + } catch { + return undefined; + } + } + return /[@?#]/.test(inner) ? undefined : raw; +} + const PLAIN_SCALAR = /^[^\s[\]{}"',#]+$/; /** A number or boolean, or a string with no escapes and no multi-line delimiter. */ @@ -194,9 +210,26 @@ function maskedAt( only.endLine === lineNumber && mayShow(only) && isPlainScalar(text.slice(only.column - 1, (only.endColumn ?? 1) - 1)); - const redactions = urlSpans(text, 1, text.length + 1); + const shownRange = printable ? only : undefined; + const redactions = urlSpans(text, 1, text.length + 1).filter( + (span) => + shownRange === undefined || + span.column >= (shownRange.endColumn ?? Infinity) || + (span.endColumn ?? Infinity) <= shownRange.column, + ); const spans: Array = []; + if (shownRange !== undefined) { + const raw = text.slice(shownRange.column - 1, (shownRange.endColumn ?? 1) - 1); + const shown = redactScalar(raw); + if (shown !== raw) { + spans.push({ + column: shownRange.column, + endColumn: shownRange.endColumn, + ...(shown === undefined ? {} : { replacement: shown }), + }); + } + } for (const value of values) { const startsHere = value.line === lineNumber; const endsHere = (value.endLine ?? value.line) === lineNumber; @@ -250,13 +283,11 @@ interface SourceView { function sourceView(report: ValidateReport): SourceView { const lines = report.source.replace(/^\uFEFF/, "").split(/\r?\n/); const publicLines = new Set(); - for (const [line, path] of cliConfigLineKeys( - report.source, - report.format === "toml" ? "toml" : "json", - )) { + const document = scanCliConfigDocument(report.source, report.format === "toml" ? "toml" : "json"); + for (const [line, path] of document.lineKeys()) { if (isPublicValuePath(path)) publicLines.add(line); } - const values = cliConfigValueSpans(report.source, report.format === "toml" ? "toml" : "json"); + const values = document.valueSpans(); if (values === undefined) return { lines, publicLines, valuesByLine: undefined }; const valuesByLine = new Map>(); for (const value of values) { diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index 5e5c777a74..8e2c5adb86 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -107,6 +107,27 @@ describe("validateReport", () => { expect(text).toContain('max_rows = "lots"'); }); + it.each([ + [`"https://u:pw'x@host.test/p?t=1#f"`, `"https://****@host.test/p?****#****"`], + ['"https://host.test/p"', '"https://host.test/p"'], + ['"user:pw@host.test"', "****"], + ['"host.test/x?token=1"', "****"], + ['"https://[bad"', "****"], + ])("prints the scalar %s as %s", (raw, shown) => { + const text = render( + [ + problem({ + path: ["api", "max_rows"], + showValue: true, + location: { line: 2, column: 12, endColumn: 12 + raw.length, exact: true }, + }), + ], + { source: `[api]\nmax_rows = ${raw}` }, + ); + + expect(text).toContain(`max_rows = ${shown}`); + }); + it("shows a known non-secret key's value on the context line", () => { const text = render([ problem({ @@ -278,7 +299,7 @@ describe("validateReport", () => { { source: URL_SOURCE }, ); - expect(text).toContain('api_url = "https://****@host.test/x?****#top"'); + expect(text).toContain('api_url = "https://****@host.test/x?****#****"'); expect(text).not.toContain("pw-secret"); expect(text).not.toContain("tok-secret"); }); @@ -294,7 +315,7 @@ describe("validateReport", () => { { source: URL_SOURCE }, ); - expect(text).toContain('api_url = "https://****@host.test/x?****#top"'); + expect(text).toContain('api_url = "https://****@host.test/x?****#****"'); expect(text).not.toContain("pw-secret"); expect(text).not.toContain("tok-secret"); }); diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index 6ba273730d..40d77e4f4c 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -5,7 +5,8 @@ import { } from "@supabase/config/effect"; import { type CliConfigLocation, - locateCliConfigKey, + scanCliConfigDocument, + type CliConfigDocumentScan, parseCliConfigDocumentFile, type ParsedCliConfigDocument, } from "@supabase/config/internal"; @@ -123,12 +124,13 @@ function removedKeyProblems( const local = displayPath(split.path); const replacementLocal = splitRemotePath(stripBrackets(replacement).split(".")).path.join("."); const isTable = key.startsWith("["); + const scope = split.remote === undefined ? "" : `remotes.${split.remote}.`; return problem("removed_key", { path: split.path, reason: "removed", help: isTable && format === "toml" - ? `rename [${local}] to [${replacementLocal}]` + ? `rename [${scope}${local}] to [${scope}${replacementLocal}]` : `use ${replacementLocal} instead`, ...(split.remote === undefined ? {} : { remote: split.remote }), }); @@ -155,7 +157,9 @@ function locateSchemaDeclaration( source: string, format: ReportFormat, ): CliConfigLocation | undefined { - if (format !== "toml") return locateCliConfigKey(source, "json", ["$schema"], "value"); + if (format !== "toml") { + return scanCliConfigDocument(source, "json").locate(["$schema"], "value"); + } const match = SCHEMA_DIRECTIVE.exec(source); if (match === null || match[1] === undefined) return undefined; const before = source.slice(0, match.index + match[0].length - match[1].length); @@ -178,8 +182,7 @@ const KEY_TARGET_CODES: ReadonlySet = new Set([ function locateProblem( item: ConfigProblem, - source: string, - format: FileFormat, + document: CliConfigDocumentScan, ): CliConfigLocation | undefined { if (item.location !== undefined || item.path === undefined || item.external !== undefined) { return item.location; @@ -193,7 +196,7 @@ function locateProblem( : item.code === "remote_project_id" ? [remotePath] : [remotePath, segments]; - const attempts = candidates.map((path) => locateCliConfigKey(source, format, path, target)); + const attempts = candidates.map((path) => document.locate(path, target)); return ( attempts.find((found) => found?.exact === true) ?? attempts.find((found) => found !== undefined) ); @@ -251,25 +254,33 @@ const schemaProblems = ( }); function invalidValueProblem(error: CliConfigValueError, file: string): ConfigProblem { - const fromEnv = error.tier === "shell" || error.tier === "projectEnv"; + const fromEnv = error.tier === "shell" || error.tier === "projectEnv" || error.tier === "flag"; + const unquotedExpected = error.expected?.replaceAll('"', ""); const reason = - error.expected !== undefined - ? `expected ${error.expected}` + unquotedExpected !== undefined + ? `expected ${unquotedExpected}` : error.issues !== undefined && error.issues.length > 0 ? error.issues.join("; ") : "invalid value"; const path = error.path.split("."); - return problem("invalid_value", { - path, - reason, - ...(error.expected?.startsWith("one of") ? { help: `use ${error.expected}` } : {}), - ...(fromEnv && error.source !== undefined - ? { - external: true as const, - help: `${error.source} sets this value, not ${file}; fix or unset it there`, - } - : { showValue: isPublicValuePath(path) }), - }); + return problem( + error.kind === "enum" + ? "invalid_enum" + : error.kind === "type" + ? "invalid_type" + : "invalid_value", + { + path, + reason, + ...(error.kind === "enum" ? { help: `use ${unquotedExpected}` } : {}), + ...(fromEnv + ? { + external: true as const, + help: `${error.source ?? "the environment"} sets this value, not ${file}; fix or unset it there`, + } + : { showValue: isPublicValuePath(path) }), + }, + ); } function semanticProblem( @@ -395,6 +406,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { ) { const source = options.source ?? (options.readSource ? yield* readSource(file) : ""); const display = relativeConfigPath(workdir, file); + const document = scanCliConfigDocument(source, format); return { file: display, format: reportFormat(display, format), @@ -404,7 +416,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { stoppedAt, problems: problems.map((item) => ({ ...item, - location: locateProblem(item, source, format), + location: locateProblem(item, document), })), } satisfies ValidateReport; }); @@ -594,6 +606,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { const base = yield* pass(undefined).pipe(Effect.withSpan("config.validate.base")); if (base.stop !== undefined) { + const document = scanCliConfigDocument(parsed.rawText, parsed.format); const report = { file, format, @@ -603,7 +616,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { stoppedAt: base.stop, problems: [...notices, ...base.problems].map((item) => ({ ...item, - location: locateProblem(item, parsed.rawText, parsed.format), + location: locateProblem(item, document), })), } satisfies ValidateReport; return yield* emit(report); @@ -712,9 +725,10 @@ export const configValidate = Effect.fn("config.validate")(function* () { const isRelevantIn = (reference: EnvReference, context: string | undefined): boolean => { if (CLI_CONFIG_OPTIONAL_ENV_PATHS[displayPath(reference.path)] !== undefined) return false; const owner = passes.get(context)?.resolved; - const gate = - nearestEnabledGate(owner?.materialized.config, reference.path) ?? - nearestEnabledGate(owner?.loaded.document, reference.path); + const gate = nearestEnabledGate( + [owner?.materialized.config, owner?.loaded.document], + reference.path, + ); return gate !== false; }; const envWarning = (reference: EnvReference, remote: string | undefined) => @@ -745,9 +759,10 @@ export const configValidate = Effect.fn("config.validate")(function* () { .filter((name) => inheritedBy(reference, name) && warns(name)) .map((name) => envWarning(reference, name)); }); + const document = scanCliConfigDocument(parsed.rawText, parsed.format); const located = [...kept, ...envWarnings].map((item) => ({ ...item, - location: locateProblem(item, parsed.rawText, parsed.format), + location: locateProblem(item, document), })); const remoteStop = remotePasses.find((item) => item.stop !== undefined)?.stop; diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 7f98ba8612..0283f26043 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -796,6 +796,72 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("scopes a removed-table rename hint to its remote", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.inbucket]\nport = 54324\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { + code: "removed_key", + remote: "prod", + help: "rename [remotes.prod.inbucket] to [remotes.prod.local_smtp]", + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports a codec enum failure from the environment as invalid_enum, unquoted", () => + Effect.gen(function* () { + const result = yield* validate(tomlFile('project_id = "demo"\n'), { + format: "json", + env: { SUPABASE_EDGE_RUNTIME_POLICY: "sideways" }, + }); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { + code: "invalid_enum", + key: "edge_runtime.policy", + reason: "expected one of per_worker, oneshot", + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("fails an undecryptable env override of a key whose file value is an unset env()", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.captcha]\nenabled = true\nprovider = "hcaptcha"\nsecret = "env(UNSET_CAPTCHA_SECRET)"\n', + ), + { format: "json", env: { SUPABASE_AUTH_CAPTCHA_SECRET: "encrypted:abcd" } }, + ); + + expect(payloadOf(result)).toMatchObject({ valid: false }); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toContainEqual( + expect.objectContaining({ key: "auth.captcha.secret" }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("does not warn about unset env() references under a disabled custom provider", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.external.myidp]\nenabled = false\nclient_id = "env(UNSET_IDP_ID)"\nsecret = "env(UNSET_IDP_SECRET)"\n', + ), + { format: "json" }, + ); + + expect(payloadOf(result)).toMatchObject({ valid: true }); + expect(Reflect.get(payloadOf(result) ?? {}, "warnings")).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("locates a remote without project_id inside that remote, not on the base key", () => Effect.gen(function* () { const result = yield* validate( @@ -1058,6 +1124,26 @@ describe("config validate integration", () => { "supabase/config.jsonc": `{\n "project_id": "demo",\n "api": {\n "schemas": [\n "a",\n "${SECRET}"], "max_rows": 1000,\n "prot": 1\n }\n}\n`, }, ], + [ + "apostrophe in URL userinfo and query on the context line", + tomlFile( + `project_id = "demo"\n[auth]\nsite_url = "https://user:pw'hunter2@host.test/x?token=tok-secret'x"\nbogus = 1\n`, + ), + ], + [ + "apostrophe in URL userinfo as the problem value", + tomlFile( + `project_id = "demo"\n[studio]\napi_url = "https://user:pw'hunter2@[bad/x?token=tok-secret"\n`, + ), + ], + [ + "scheme-less credential string on the context line", + tomlFile(`project_id = "demo"\n[auth]\nsite_url = "user:hunter2@host.test"\nbogus = 1\n`), + ], + [ + "literal dotted key that looks like a public path", + tomlFile(`project_id = "demo"\n"db.port" = "hunter2"\nbogus = 1\n`), + ], [ "schema reference carrying credentials", tomlFile( diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts index 24571581e4..833249b8a3 100644 --- a/apps/cli/src/commands/config/validate/validate.model.ts +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -49,13 +49,14 @@ export function splitRemotePath(path: ReadonlyArray): { : { path }; } -const ROOT_KEY_PATH = "db.root_key"; +const SEGMENT_SEPARATOR = "\0"; +const ROOT_KEY_PATH = ["db", "root_key"].join(SEGMENT_SEPARATOR); const SECRET_LOOKING = /secret|pass|token|key|jwt|salt|credential|vault/i; const publicLeafPaths: ReadonlySet = new Set( cliConfigRegistry.keys .filter((key) => key.document !== false && key.secret !== true) - .map((key) => key.path), + .map((key) => key.path.split(".").join(SEGMENT_SEPARATOR)), ); const publicFamilyLeaves = cliConfigRegistry.families.map((family) => ({ @@ -79,7 +80,7 @@ const isPublicFamilyLeaf = (segments: ReadonlyArray): boolean => */ export function isPublicValuePath(path: ReadonlyArray): boolean { const segments = splitRemotePath(path).path; - const local = segments.join("."); + const local = segments.join(SEGMENT_SEPARATOR); return ( local !== ROOT_KEY_PATH && !isSecretPath(segments) && @@ -221,16 +222,18 @@ const readPath = (root: unknown, path: ReadonlyArray): unknown => { }; /** - * The value of the nearest enclosing table's `enabled` switch on an owning pass's resolved - * config, or `undefined` when no enclosing table has one. + * The value of the nearest enclosing table's `enabled` switch, trying each representation at every + * depth before moving outward, or `undefined` when no enclosing table has one. */ export function nearestEnabledGate( - config: unknown, + configs: ReadonlyArray, path: ReadonlyArray, ): boolean | undefined { for (let depth = path.length - 1; depth > 0; depth--) { - const enabled = readPath(config, [...path.slice(0, depth), "enabled"]); - if (typeof enabled === "boolean") return enabled; + for (const config of configs) { + const enabled = readPath(config, [...path.slice(0, depth), "enabled"]); + if (typeof enabled === "boolean") return enabled; + } } return undefined; } diff --git a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts index aff0fb0867..e14f18d167 100644 --- a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts @@ -54,6 +54,11 @@ describe("isPublicValuePath", () => { ])("masks the value of %s", (path) => { expect(isPublicValuePath(path.split("."))).toBe(false); }); + + it("keeps segment boundaries, so a quoted dotted key is not the nested path", () => { + expect(isPublicValuePath(["db.port"])).toBe(false); + expect(isPublicValuePath(["db", "port"])).toBe(true); + }); }); describe("dedupeProblems", () => { @@ -181,12 +186,18 @@ describe("nearestEnabledGate", () => { }; it("uses the nearest enclosing table that has an enabled switch", () => { - expect(nearestEnabledGate(config, ["auth", "external", "github", "secret"])).toBe(false); - expect(nearestEnabledGate(config, ["auth", "site_url"])).toBe(true); + expect(nearestEnabledGate([config], ["auth", "external", "github", "secret"])).toBe(false); + expect(nearestEnabledGate([config], ["auth", "site_url"])).toBe(true); + }); + + it("prefers a nearer gate in any representation over a farther one", () => { + const decoded = { auth: { enabled: true, external: {} } }; + const raw = { auth: { external: { myidp: { enabled: false } } } }; + expect(nearestEnabledGate([decoded, raw], ["auth", "external", "myidp", "secret"])).toBe(false); }); it("is undefined when no enclosing table has one", () => { - expect(nearestEnabledGate(config, ["db", "port"])).toBeUndefined(); - expect(nearestEnabledGate(undefined, ["db", "port"])).toBeUndefined(); + expect(nearestEnabledGate([config], ["db", "port"])).toBeUndefined(); + expect(nearestEnabledGate([undefined], ["db", "port"])).toBeUndefined(); }); }); diff --git a/apps/cli/src/config/cli-config-key.ts b/apps/cli/src/config/cli-config-key.ts index b2cac41204..ca4f8ac925 100644 --- a/apps/cli/src/config/cli-config-key.ts +++ b/apps/cli/src/config/cli-config-key.ts @@ -406,6 +406,7 @@ const invalidCliConfigValue = ( ): CliConfigValueError => { const { path, raw, expected, origin, typeOnly } = failure; const source = describeCliConfigOrigin(origin, ctx); + const kind = typeOnly ? "type" : expected.startsWith("one of") ? "enum" : "value"; switch (origin.tier) { case "flag": return new CliConfigValueError({ @@ -414,6 +415,7 @@ const invalidCliConfigValue = ( source, flag: origin.flag, expected, + kind, message: `Invalid --${origin.flag}="${raw}" (sets ${path}): expected ${expected}.`, }); case "shell": @@ -428,6 +430,7 @@ const invalidCliConfigValue = ( source, envName: origin.envName, expected, + kind, message: `Invalid ${origin.envName}="${raw}"${file} (sets ${path}): expected ${expected}.`, }); } @@ -438,6 +441,7 @@ const invalidCliConfigValue = ( tier: "config", source, expected, + kind, message: typeOnly ? `Invalid ${path}${where === "config" ? "" : ` in ${where}`}: expected ${expected}, got ${raw}.` : where === "config" @@ -479,7 +483,7 @@ export const pickCliConfigKey = => { if (!secret || typeof raw !== "string" || !isEncryptedSecret(raw)) return Result.succeed(raw); @@ -489,7 +493,11 @@ export const pickCliConfigKey = ; }> { diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 0579bbc678..26b51ae68a 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -39,7 +39,7 @@ artifacts (`./schema.json`, `./project-schema.json`). otherwise-internal registry data (`AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, `ProjectConfigMappingRow`, `ProjectConfigApiAttributes`, `ENV_CAPTURE_REGEX`, and `withoutSafeIntegerBounds` for the CLI's published lock schema), the - JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `locateCliConfigKey`/`CliConfigLocation` (line/column of a key or value in raw TOML/JSON/JSONC text), `cliConfigLineKeys` (the key path each single-entry line holds), `cliConfigValueSpans` (the span of every scalar or array value, multi-line ones included) and `isSecretPath`. It also carries + JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `scanCliConfigDocument` (a caller-owned scan of raw TOML/JSON/JSONC text whose `locate` finds the line/column of a key or value, `lineKeys` the key path each single-entry line holds and `valueSpans` the span of every scalar or array value, multi-line ones included; `CliConfigLocation` is a position) and `isSecretPath`. It also carries `supabase config pull`/`diff`'s own support surface (CLI-2064): the format-preserving surgical editor (`applyConfigEdits` and its `ConfigEdit`/`ConfigEditOutcome`/`ConfigEditRefusal`/`ConfigEditRefusalReason`/`ConfigEditValue`/ diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index 1148da59ab..f57a0a7a17 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -228,26 +228,21 @@ function equals(path: ReadonlyArray, other: ReadonlyArray): bool } interface ScannedToml { - readonly source: string; readonly scan: TomlScanResult | TomlScanError; readonly starts: ReadonlyArray; } -let lastScanned: ScannedToml | undefined; - -function scanOnce(source: string): ScannedToml { - if (lastScanned?.source === source) return lastScanned; - lastScanned = { source, scan: scanTomlDocument(source), starts: lineStarts(source) }; - return lastScanned; -} +const scanToml = (source: string): ScannedToml => ({ + scan: scanTomlDocument(source), + starts: lineStarts(source), +}); function locateToml( - text: string, + source: string, path: ReadonlyArray, target: "key" | "value", + { scan, starts }: ScannedToml, ): CliConfigLocation | undefined { - const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; - const { scan, starts } = scanOnce(source); if (isScanError(scan)) return undefined; const keys = scan.tokens.filter((token): token is TomlKeyToken => token.kind === "kv"); const spanOf = (token: TomlKeyToken) => @@ -299,11 +294,10 @@ function childOf(node: Node, segment: string): { entry: Node; value: Node } | un } function locateJson( - text: string, + source: string, path: ReadonlyArray, target: "key" | "value", ): CliConfigLocation | undefined { - const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; const root = parseTree(source, [], { allowTrailingComma: true, disallowComments: false }); if (root === undefined) return undefined; const starts = lineStarts(source); @@ -328,21 +322,7 @@ function locateJson( return undefined; } -/** - * Finds where `path` sits in raw config text, falling back to the nearest enclosing ancestor - * (`exact: false`) when the key itself is absent; undefined when the text cannot be scanned. - */ -export function locateCliConfigKey( - source: string, - format: ConfigFormat, - path: ReadonlyArray, - target: "key" | "value", -): CliConfigLocation | undefined { - return format === "toml" ? locateToml(source, path, target) : locateJson(source, path, target); -} - -function tomlLineKeys(source: string): ReadonlyMap> { - const { scan, starts } = scanOnce(source); +function tomlLineKeys({ scan, starts }: ScannedToml): ReadonlyMap> { const found = new Map>(); if (isScanError(scan)) return found; for (const token of scan.tokens) { @@ -394,18 +374,6 @@ function jsonLineKeys(source: string): ReadonlyMap return found; } -/** - * The key path of every 1-based line that holds one complete key and its scalar or flat-array - * value and nothing else; empty when the text cannot be parsed. - */ -export function cliConfigLineKeys( - text: string, - format: ConfigFormat, -): ReadonlyMap> { - const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; - return format === "toml" ? tomlLineKeys(source) : jsonLineKeys(source); -} - function tomlValueSpans(source: string, span: Span): ReadonlyArray { if (source[span.start] !== "{") return [span]; const text = source.slice(span.start, span.end); @@ -434,24 +402,19 @@ function jsonValueSpans(root: Node): ReadonlyArray { return found; } -/** - * The span of every scalar or array value in raw config text, including ones that cover several - * lines; undefined when the text cannot be parsed. - */ -export function cliConfigValueSpans( - text: string, - format: ConfigFormat, +function tomlDocumentValueSpans( + source: string, + { scan, starts }: ScannedToml, ): ReadonlyArray | undefined { - const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; - if (format === "toml") { - const { scan, starts } = scanOnce(source); - if (isScanError(scan)) return undefined; - return scan.tokens - .filter((token): token is TomlKeyToken => token.kind === "kv") - .flatMap((token) => tomlValueSpans(source, { start: token.valueStart, end: token.valueEnd })) - .filter((span) => span.end > span.start) - .map((span) => toLocation(starts, span, true)); - } + if (isScanError(scan)) return undefined; + return scan.tokens + .filter((token): token is TomlKeyToken => token.kind === "kv") + .flatMap((token) => tomlValueSpans(source, { start: token.valueStart, end: token.valueEnd })) + .filter((span) => span.end > span.start) + .map((span) => toLocation(starts, span, true)); +} + +function jsonDocumentValueSpans(source: string): ReadonlyArray | undefined { const errors: Array = []; const root = parseTree(source, errors, { allowTrailingComma: true, disallowComments: false }); if (root === undefined || errors.length > 0) return undefined; @@ -460,3 +423,36 @@ export function cliConfigValueSpans( .filter((span) => span.end > span.start) .map((span) => toLocation(starts, span, true)); } + +/** One scan of raw config text, owned by the caller so the text is not retained beyond it. */ +export interface CliConfigDocumentScan { + /** + * Finds where `path` sits, falling back to the nearest enclosing ancestor (`exact: false`) when + * the key itself is absent; undefined when the text cannot be scanned. + */ + readonly locate: ( + path: ReadonlyArray, + target: "key" | "value", + ) => CliConfigLocation | undefined; + /** The key path of every 1-based line that holds one complete key and its value and nothing else. */ + readonly lineKeys: () => ReadonlyMap>; + /** The span of every scalar or array value, multi-line ones included; undefined when unparseable. */ + readonly valueSpans: () => ReadonlyArray | undefined; +} + +export function scanCliConfigDocument(text: string, format: ConfigFormat): CliConfigDocumentScan { + const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + if (format === "toml") { + const scanned = scanToml(source); + return { + locate: (path, target) => locateToml(source, path, target, scanned), + lineKeys: () => tomlLineKeys(scanned), + valueSpans: () => tomlDocumentValueSpans(source, scanned), + }; + } + return { + locate: (path, target) => locateJson(source, path, target), + lineKeys: () => jsonLineKeys(source), + valueSpans: () => jsonDocumentValueSpans(source), + }; +} diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index 377335828c..2d28d22b89 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -1,5 +1,16 @@ import { describe, expect, test } from "vitest"; -import { cliConfigLineKeys, cliConfigValueSpans, locateCliConfigKey } from "./config-locate.ts"; +import { scanCliConfigDocument } from "./config-locate.ts"; + +const locateCliConfigKey = ( + source: string, + format: "toml" | "json", + path: ReadonlyArray, + target: "key" | "value", +) => scanCliConfigDocument(source, format).locate(path, target); +const cliConfigLineKeys = (source: string, format: "toml" | "json") => + scanCliConfigDocument(source, format).lineKeys(); +const cliConfigValueSpans = (source: string, format: "toml" | "json") => + scanCliConfigDocument(source, format).valueSpans(); import { isSecretPath } from "./lib/secret-paths.ts"; const toml = [ diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 6bd0b7a361..813b25e3b4 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -527,15 +527,12 @@ describe("src/internal.ts export surface", () => { "PROVIDER_SIGNUP_ALIASES", "applyConfigEdits", "cliConfigFormatForPath", - "cliConfigLineKeys", - "cliConfigValueSpans", "decodeCliConfigDocumentForValidationEffect", "decodeMergedCliConfig", "dualScopeProjectConfigPaths", "findUnknownCliConfigKeys", "isSecretPath", "loadCliConfig", - "locateCliConfigKey", "mapMigrationCheckRules", "mergeParsedCliConfig", "parseCliConfigDocument", @@ -547,6 +544,7 @@ describe("src/internal.ts export surface", () => { "remoteProjectIdEntries", "resolveCliConfigSubtree", "resolveCliConfigValue", + "scanCliConfigDocument", "unmappedSecretApiPaths", "withoutSafeIntegerBounds", "writeCliConfigDocumentText", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index c1f6456293..9af495f864 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -55,8 +55,7 @@ export { CliConfigWriteError } from "./errors.ts"; export { findUnknownCliConfigKeys, type UnknownCliConfigKey } from "./unknown-keys.ts"; export { isSecretPath } from "./lib/secret-paths.ts"; export { - cliConfigLineKeys, - cliConfigValueSpans, - locateCliConfigKey, + scanCliConfigDocument, + type CliConfigDocumentScan, type CliConfigLocation, } from "./config-locate.ts"; From 2df0c0942f7af2b0184a613d657c74d898912bc2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 11 Nov 2026 09:45:07 +0000 Subject: [PATCH 06/12] fix(cli): show keys and positions only in config validate frames Code frames now mask every value and the text of every comment, so a report never prints configured values. This removes the public-value allowlist, URL redaction and per-problem value display, and the isSecretPath export from @supabase/config/internal that only they used. --- apps/cli/docs/supabase/config/validate.md | 4 +- .../commands/config/validate/SIDE_EFFECTS.md | 2 +- .../config/validate/validate.format.ts | 199 +++++---------- .../validate/validate.format.unit.test.ts | 236 +++++++----------- .../config/validate/validate.handler.ts | 20 +- .../validate/validate.integration.test.ts | 32 +-- .../config/validate/validate.model.ts | 46 +--- .../validate/validate.model.unit.test.ts | 30 --- packages/config/AGENTS.md | 2 +- packages/config/src/config-locate.ts | 56 ----- .../config/src/config-locate.unit.test.ts | 37 --- .../config/src/entrypoint-purity.unit.test.ts | 1 - packages/config/src/internal.ts | 1 - 13 files changed, 172 insertions(+), 494 deletions(-) diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md index b28a0dafc9..58133811d8 100644 --- a/apps/cli/docs/supabase/config/validate.md +++ b/apps/cli/docs/supabase/config/validate.md @@ -12,11 +12,11 @@ Checks the project config file (`supabase/config.toml`, `config.json` or `config 6. The validations `supabase start` applies, such as a missing provider secret or an unreadable `content_path` file. 7. Every `[remotes.]` block, merged over the base config. Its problems carry the remote name. -Stages 1 to 4 stop the run when they fail, because later checks cannot be computed; the report says where it stopped (`complete: false`, `stopped_at` in JSON). A schema check that keeps finding dependent failures stops at `schema_limit` with the failures found so far. A remote `project_id` set by a `SUPABASE_REMOTES_*` variable is reported against that variable, not the file. Code frames print a value only when it is the sole plain scalar on its line; everything else is masked as `****`. Everything from stage 5 on is reported together. +Stages 1 to 4 stop the run when they fail, because later checks cannot be computed; the report says where it stopped (`complete: false`, `stopped_at` in JSON). A schema check that keeps finding dependent failures stops at `schema_limit` with the failures found so far. A remote `project_id` set by a `SUPABASE_REMOTES_*` variable is reported against that variable, not the file. Everything from stage 5 on is reported together. ## Secrets -Values print as `****` unless the key is a known non-secret setting. `env(NAME)` references stay visible, and JSON output never contains source text or values. A value that comes from a `SUPABASE_*` variable is reported against its key, not a line. +Code frames show keys, headers and positions, never values: every value, on every line it spans, prints as `****`, and the text of comments is masked after its `#` or `//`. JSON output never contains source text or values. A value that comes from a `SUPABASE_*` variable is reported against its key, not a line. ## `env()` references diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index 371af90eee..ef6f121cd1 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -55,7 +55,7 @@ Each stage reports every problem it finds; a run stops after a stage only when t ### `--output-format text` -stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Values are masked as `****`, on every line they span, unless the key is a known non-secret setting and its value is the only value on that line and is a plain number, boolean or string without escapes or multi-line delimiters; arrays, tables and everything else on the line stay masked, and a shown string that holds a URL is printed as `scheme://****@host/path?****#****` (credentials, query and fragment redacted) and one that looks like credentials or cannot be parsed is masked whole; `env(NAME)` references stay visible. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Code frames show keys, table headers and positions, never values: every value is masked as `****` on every line it spans, and the text of comments is masked after its `#` or `//`. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. ### `--output-format json` diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index ed2a363884..c850f01fd8 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -1,7 +1,7 @@ import { escapeControlCharacters } from "../../../shared/output/escape-control-characters.ts"; import { scanCliConfigDocument, type CliConfigLocation } from "@supabase/config/internal"; -import { displayPath, isPublicValuePath, type ConfigProblem } from "./validate.model.ts"; +import { displayPath, type ConfigProblem } from "./validate.model.ts"; /** ANSI styling applied to the text report; the handler passes stream-gated colours. */ export interface ReportStyle { @@ -86,15 +86,12 @@ const display = (text: string): string => interface Span { readonly column: number; readonly endColumn?: number; - readonly replacement?: string; } -const KEY_VALUE = - /^(\s*(?:(?:#|\/\/)\s*)?(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+))*\s*[=:]\s*)(.*)$/; -const ENV_REFERENCE_VALUE = /^["']env\([A-Za-z_][A-Za-z0-9_]*\)["']\s*,?\s*$/; -const URL_USERINFO = /[a-z][a-z0-9+.-]*:\/\/([^\s/?#"]*)@/gi; -const URL_QUERY = /[a-z][a-z0-9+.-]*:\/\/[^\s"?#]*\?([^\s"#]*)/gi; -const TRAILING_COMMENT = /^[\s,}\]]*(#|\/\/)/; +const COMMENT_MARKER = /#|\/\/|\/\*/; +const COMMENT_LINE = /^\s*(?:#|\/\/|\/\*|\*)[ \t]*/; +const TOML_HEADER = /^\s*\[\[?[^#]*?\]\]?\s*(?=#|$)/; +const JSON_STRUCTURE = /^\s*(?:"(?:[^"\\]|\\.)*"\s*:\s*)?[{[]?[}\]]*\s*,?\s*$/; interface MaskedLine { readonly text: string; @@ -106,18 +103,17 @@ function maskLine(text: string, spans: ReadonlyArray): MaskedLine { .map((span) => ({ start: Math.min(span.column - 1, text.length), end: span.endColumn === undefined ? text.length : Math.min(span.endColumn - 1, text.length), - replacement: span.replacement ?? MASK, })) .sort((left, right) => left.start - right.start); let out = ""; let position = 0; - const applied: Array<{ start: number; end: number; outStart: number; length: number }> = []; + const applied: Array<{ start: number; end: number; outStart: number }> = []; for (const range of ranges) { if (range.end <= position) continue; const start = Math.max(range.start, position); out += text.slice(position, start); - applied.push({ start, end: range.end, outStart: out.length, length: range.replacement.length }); - out += range.replacement; + applied.push({ start, end: range.end, outStart: out.length }); + out += MASK; position = range.end; } out += text.slice(position); @@ -128,139 +124,67 @@ function maskLine(text: string, spans: ReadonlyArray): MaskedLine { for (const range of applied) { if (index < range.start) break; if (index < range.end) return range.outStart; - shift += range.length - (range.end - range.start); + shift += MASK.length - (range.end - range.start); } return index + shift; }, }; } -/** The credentials and query string of every URL in `text` from `from` to `to` (1-based, `to` exclusive). */ -function urlSpans(text: string, from: number, to: number): ReadonlyArray { - const slice = text.slice(from - 1, to - 1); - const spans: Array = []; - for (const match of slice.matchAll(URL_USERINFO)) { - const userinfo = match[1] ?? ""; - const start = from + match.index + match[0].length - 1 - userinfo.length; - if (userinfo.length > 0) spans.push({ column: start, endColumn: start + userinfo.length }); - } - for (const match of slice.matchAll(URL_QUERY)) { - const query = match[1] ?? ""; - const start = from + match.index + match[0].length - query.length; - if (query.length > 0) spans.push({ column: start, endColumn: start + query.length }); - } - return spans; +/** The text of every comment in `gap`, which starts at 0-based `offset` of its line. */ +function commentSpans(gap: string, offset: number): ReadonlyArray { + const index = gap.search(COMMENT_MARKER); + if (index < 0) return []; + const block = gap.startsWith("/*", index); + const markerLength = gap.startsWith("#", index) ? 1 : 2; + const close = block ? gap.indexOf("*/", index + markerLength) : -1; + const end = close < 0 ? gap.length : close; + let start = index + markerLength; + while (start < end && (gap[start] === " " || gap[start] === "\t")) start += 1; + const here: ReadonlyArray = + start < end ? [{ column: offset + start + 1, endColumn: offset + end + 1 }] : []; + return close < 0 ? here : [...here, ...commentSpans(gap.slice(close + 2), offset + close + 2)]; } -/** The scalar as it may print: URL credentials, query and fragment replaced; undefined when it must be masked whole. */ -function redactScalar(raw: string): string | undefined { - const quote = raw[0] === '"' || raw[0] === "'" ? raw[0] : ""; - const inner = quote === "" ? raw : raw.slice(1, -1); - if (inner.includes("://")) { - try { - const url = new URL(inner); - const userinfo = url.username !== "" || url.password !== "" ? `${MASK}@` : ""; - const search = url.search === "" ? "" : `?${MASK}`; - const hash = url.hash === "" ? "" : `#${MASK}`; - return `${quote}${url.protocol}//${userinfo}${url.host}${url.pathname}${search}${hash}${quote}`; - } catch { - return undefined; - } +function unvaluedSpans(text: string, format: SourceView["format"]): ReadonlyArray { + if (format === "toml") { + const header = TOML_HEADER.exec(text); + if (header !== null) return commentSpans(text.slice(header[0].length), header[0].length); + } else if (JSON_STRUCTURE.test(text)) { + return []; } - return /[@?#]/.test(inner) ? undefined : raw; -} - -const PLAIN_SCALAR = /^[^\s[\]{}"',#]+$/; - -/** A number or boolean, or a string with no escapes and no multi-line delimiter. */ -function isPlainScalar(text: string): boolean { - const quote = text[0]; - if (quote === '"' || quote === "'") { - return ( - text.length >= 2 && - text.endsWith(quote) && - !text.startsWith(quote.repeat(3)) && - !text.includes("\\") - ); - } - return PLAIN_SCALAR.test(text); + const marker = COMMENT_LINE.exec(text); + return [{ column: (marker?.[0].length ?? text.length - text.trimStart().length) + 1 }]; } /** - * A source line with every value masked, whichever lines it spans. A value prints only when - * `mayShow` and it is the only value touching a line, one plain scalar that starts and ends on it; - * URL credentials and query strings are redacted either way. Source that cannot be parsed is - * masked in full. + * A source line with every value, and the text of every comment, masked. Keys, headers and + * punctuation stay. Source that cannot be parsed is masked in full. */ -function maskedAt( - { lines, valuesByLine }: SourceView, - lineNumber: number, - mayShow: (value: CliConfigLocation) => boolean = () => false, -): MaskedLine { +function maskedAt({ lines, valuesByLine, format }: SourceView, lineNumber: number): MaskedLine { const text = lines[lineNumber - 1] ?? ""; const indent = text.length - text.trimStart().length; if (text.trim() === "") return maskLine(text, []); if (valuesByLine === undefined) return maskLine(text, [{ column: indent + 1 }]); const values = valuesByLine.get(lineNumber) ?? []; - const only = values.length === 1 ? values[0] : undefined; - const printable = - only !== undefined && - only.line === lineNumber && - only.endLine === lineNumber && - mayShow(only) && - isPlainScalar(text.slice(only.column - 1, (only.endColumn ?? 1) - 1)); - const shownRange = printable ? only : undefined; - const redactions = urlSpans(text, 1, text.length + 1).filter( - (span) => - shownRange === undefined || - span.column >= (shownRange.endColumn ?? Infinity) || - (span.endColumn ?? Infinity) <= shownRange.column, - ); + if (values.length === 0) return maskLine(text, unvaluedSpans(text, format)); - const spans: Array = []; - if (shownRange !== undefined) { - const raw = text.slice(shownRange.column - 1, (shownRange.endColumn ?? 1) - 1); - const shown = redactScalar(raw); - if (shown !== raw) { - spans.push({ - column: shownRange.column, - endColumn: shownRange.endColumn, - ...(shown === undefined ? {} : { replacement: shown }), - }); - } - } - for (const value of values) { - const startsHere = value.line === lineNumber; - const endsHere = (value.endLine ?? value.line) === lineNumber; - const column = startsHere ? value.column : indent + 1; - const endColumn = endsHere ? value.endColumn : undefined; - const isEnvReference = - startsHere && - endsHere && - ENV_REFERENCE_VALUE.test(text.slice(column - 1, (endColumn ?? text.length + 1) - 1)); - if (!printable && !isEnvReference) { - spans.push(endColumn === undefined ? { column } : { column, endColumn }); - } - } - if (values.length === 0) { - const pair = /^\s*(?:#|\/\/)/.test(text) ? KEY_VALUE.exec(text) : null; - return maskLine(text, [ - ...redactions, - ...(pair?.[1] === undefined ? [] : [{ column: pair[1].length + 1 }]), - ]); - } - const lastEnd = - Math.max( - ...values.map((value) => - value.endLine === lineNumber ? (value.endColumn ?? 1) : text.length + 1, - ), - ) - 1; - const comment = TRAILING_COMMENT.exec(text.slice(lastEnd)); - if (comment !== null) { - spans.push({ column: lastEnd + comment[0].length - (comment[1] ?? "").length + 1 }); + const spans: Array = values + .map((value) => { + const column = value.line === lineNumber ? value.column : indent + 1; + const endColumn = (value.endLine ?? value.line) === lineNumber ? value.endColumn : undefined; + return endColumn === undefined ? { column } : { column, endColumn }; + }) + .sort((left, right) => left.column - right.column); + const comments: Array = []; + let position = 0; + for (const span of spans) { + comments.push(...commentSpans(text.slice(position, span.column - 1), position)); + position = span.endColumn === undefined ? text.length : span.endColumn - 1; } - return maskLine(text, [...spans, ...redactions]); + comments.push(...commentSpans(text.slice(position), position)); + return maskLine(text, [...spans, ...comments]); } function windowAround(text: string, caretOffset: number): { text: string; caretOffset: number } { @@ -274,28 +198,23 @@ function windowAround(text: string, caretOffset: number): { text: string; caretO interface SourceView { readonly lines: ReadonlyArray; - /** 1-based lines holding a single non-secret schema leaf, whose value context lines may show. */ - readonly publicLines: ReadonlySet; + readonly format: "toml" | "json"; /** The value spans touching each 1-based line; undefined when the source cannot be parsed. */ readonly valuesByLine: ReadonlyMap> | undefined; } function sourceView(report: ValidateReport): SourceView { const lines = report.source.replace(/^\uFEFF/, "").split(/\r?\n/); - const publicLines = new Set(); - const document = scanCliConfigDocument(report.source, report.format === "toml" ? "toml" : "json"); - for (const [line, path] of document.lineKeys()) { - if (isPublicValuePath(path)) publicLines.add(line); - } - const values = document.valueSpans(); - if (values === undefined) return { lines, publicLines, valuesByLine: undefined }; + const format = report.format === "toml" ? "toml" : "json"; + const values = scanCliConfigDocument(report.source, format).valueSpans(); + if (values === undefined) return { lines, format, valuesByLine: undefined }; const valuesByLine = new Map>(); for (const value of values) { for (let line = value.line; line <= (value.endLine ?? value.line); line++) { valuesByLine.set(line, [...(valuesByLine.get(line) ?? []), value]); } } - return { lines, publicLines, valuesByLine }; + return { lines, format, valuesByLine }; } function frameLines( @@ -303,7 +222,7 @@ function frameLines( view: SourceView, style: ReportStyle, ): ReadonlyArray { - const { lines, publicLines } = view; + const { lines } = view; const location = problem.location; const first = location === undefined ? undefined : lines[location.line - 1]; if (location === undefined || first === undefined) return []; @@ -318,7 +237,7 @@ function frameLines( const out: Array = []; if (location.line > 1) { - const before = maskedAt(view, location.line - 1, () => publicLines.has(location.line - 1)).text; + const before = maskedAt(view, location.line - 1).text; const shown = display(before).trimEnd(); const clipped = shown.length > MAX_FRAME_WIDTH ? `${shown.slice(0, MAX_FRAME_WIDTH)}…` : shown; out.push( @@ -326,13 +245,7 @@ function frameLines( ); } - const mayShow = (value: CliConfigLocation) => - problem.showValue && - location.exact && - value.line === location.line && - value.column === location.column && - value.endColumn === location.endColumn; - const current = maskedAt(view, location.line, mayShow); + const current = maskedAt(view, location.line); const start = current.map(location.column - 1); const singleLine = location.endLine === undefined || location.endLine === location.line; const end = diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index 8e2c5adb86..81527da229 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -22,7 +22,6 @@ const problem = (fields: Partial): ConfigProblem => ({ code: "invalid_type", severity: "error", reason: "bad", - showValue: false, ...fields, }); @@ -79,11 +78,25 @@ describe("validateReport", () => { expect(text).not.toContain("hunter2"); }); - it("shows the located value only when it is the only value on its line", () => { + it("masks a public scalar and keeps its key", () => { + const text = render( + [ + problem({ + path: ["api", "enabled"], + location: { line: 2, column: 11, endColumn: 15, exact: true }, + }), + ], + { source: "[api]\nenabled = true" }, + ); + + expect(text).toContain(" > 2 │ enabled = ****"); + expect(text).not.toContain("true"); + }); + + it("masks every value in an inline table, keeping keys and punctuation", () => { const text = render([ problem({ path: ["api", "cfg", "n"], - showValue: true, location: { line: 6, column: 32, endColumn: 37, exact: true }, }), ]); @@ -92,74 +105,98 @@ describe("validateReport", () => { expect(text).not.toContain("hunter2"); }); - it("shows a located scalar that is alone on its line", () => { + it("masks a url, a nested table and an array alike", () => { + const text = render([problem({ path: ["x"], location: { line: 4, column: 1, exact: true } })], { + source: [ + 'url = "https://user:pw\'secret@host.test/x?token=tok-secret#top"', + 'tables = [{ secret = "hunter2" }]', + 'schemas = ["a", "b"]', + "k = 1", + ].join("\n"), + }); + + expect(text).toContain(" > 4 │ k = ****"); + expect(text).toContain("3 │ schemas = ****"); + expect(text).not.toMatch(/hunter2|secret|host\.test/); + }); + + it("masks the value on the context line", () => { + const text = render([ + problem({ + path: ["api", "cfg"], + location: { line: 6, column: 1, endColumn: 4, exact: true }, + }), + ]); + + expect(text).toContain("5 │ port = ****"); + }); + + it("masks the text of a TOML comment and keeps its marker", () => { const text = render( - [ - problem({ - path: ["api", "max_rows"], - showValue: true, - location: { line: 2, column: 12, endColumn: 18, exact: true }, - }), - ], - { source: '[api]\nmax_rows = "lots"' }, + [problem({ path: ["x"], location: { line: 3, column: 1, endColumn: 2, exact: true } })], + { source: '# secret = "hunter2"\n[api] # token hunter2\nk = 1 # note hunter2' }, ); - expect(text).toContain('max_rows = "lots"'); + expect(text).toContain("2 │ [api] # ****"); + expect(text).toContain("> 3 │ k = **** # ****"); + expect(text).not.toContain("hunter2"); + }); + + it("masks commented-out lines before the problem", () => { + const text = render([problem({ path: ["x"], location: { line: 2, column: 1, exact: true } })], { + source: '# token = "hunter2"\nk = 1', + }); + + expect(text).toContain("1 │ # ****"); + expect(text).not.toContain("hunter2"); }); - it.each([ - [`"https://u:pw'x@host.test/p?t=1#f"`, `"https://****@host.test/p?****#****"`], - ['"https://host.test/p"', '"https://host.test/p"'], - ['"user:pw@host.test"', "****"], - ['"host.test/x?token=1"', "****"], - ['"https://[bad"', "****"], - ])("prints the scalar %s as %s", (raw, shown) => { + it("masks JSONC values and comments but keeps keys and structure", () => { + const source = [ + "{", + ' "api": {', + ' // "token": "hunter2"', + ' "enabled": true, /* hunter2 */ "n": 1 // hunter2', + " }", + "}", + ].join("\n"); const text = render( [ problem({ - path: ["api", "max_rows"], - showValue: true, - location: { line: 2, column: 12, endColumn: 12 + raw.length, exact: true }, + path: ["api", "n"], + location: { line: 4, column: 38, endColumn: 39, exact: true }, }), ], - { source: `[api]\nmax_rows = ${raw}` }, + { format: "jsonc", source }, ); - expect(text).toContain(`max_rows = ${shown}`); - }); - - it("shows a known non-secret key's value on the context line", () => { - const text = render([ - problem({ - path: ["api", "cfg"], - location: { line: 6, column: 1, endColumn: 4, exact: true }, - }), - ]); - - expect(text).toContain("5 │ port = 5"); + expect(text).toContain("3 │ // ****"); + expect(text).toContain('> 4 │ "enabled": ****, /* *****/ "n": **** // ****'); + expect(text).not.toContain("hunter2"); }); - it("masks a secret on the context line", () => { - const text = render([ - problem({ - path: ["api", "port"], - location: { line: 5, column: 1, endColumn: 5, exact: true }, - }), - ]); + it("masks the interior of a JSONC block comment", () => { + const source = '{\n /*\n "token": "hunter2"\n */\n "k": 1\n}'; + const text = render([problem({ path: ["k"], location: { line: 4, column: 3, exact: true } })], { + format: "jsonc", + source, + }); - expect(text).toContain("4 │ secret = ****"); expect(text).not.toContain("hunter2"); }); - it("masks an unknown key's value on the context line", () => { - const text = render([ - problem({ - path: ["api", "secret"], - location: { line: 4, column: 1, endColumn: 7, exact: true }, - }), - ]); + it("underlines the masked value, not the characters it replaced", () => { + const text = render( + [ + problem({ + path: ["api", "max_rows"], + location: { line: 2, column: 12, endColumn: 18, exact: true }, + }), + ], + { source: '[api]\nmax_rows = "lots"' }, + ); - expect(text).toContain("3 │ prot = ****"); + expect(text).toContain(" > 2 │ max_rows = ****\n │ ^^^^"); }); describe("multi-line secrets", () => { @@ -227,107 +264,12 @@ describe("validateReport", () => { }); }); - describe("shown values", () => { - it("masks the nested values of an inline table at a shown location", () => { - const source = '[api]\nmax_rows = { secret = "hunter2" }'; - const text = render( - [ - problem({ - path: ["api", "max_rows"], - showValue: true, - location: { line: 2, column: 12, endColumn: 39, exact: true }, - }), - ], - { source }, - ); - - expect(text).not.toContain("hunter2"); - }); - - it("masks a table nested in a shown array", () => { - const source = '[api]\nschemas = ["a", { secret = "hunter2" }]'; - const text = render( - [ - problem({ - path: ["api", "schemas"], - showValue: true, - location: { line: 2, column: 11, endColumn: 44, exact: true }, - }), - ], - { source }, - ); - - expect(text).not.toContain("hunter2"); - }); - - it("masks a flat array even at a shown location", () => { - const source = '[api]\nschemas = ["a", "b"]'; - const text = render( - [ - problem({ - path: ["api", "schemas"], - showValue: true, - location: { line: 2, column: 11, endColumn: 21, exact: true }, - }), - ], - { source }, - ); - - expect(text).toContain("schemas = ****"); - }); - - const URL_SOURCE = [ - "[studio]", - 'api_url = "https://user:pw-secret@host.test/x?token=tok-secret#top"', - 'port = "bad"', - ].join("\n"); - - it("redacts credentials and the query string of a shown problem value", () => { - const text = render( - [ - problem({ - path: ["studio", "api_url"], - showValue: true, - location: { - line: 2, - column: 11, - endColumn: (URL_SOURCE.split("\n")[1]?.length ?? 0) + 1, - exact: true, - }, - }), - ], - { source: URL_SOURCE }, - ); - - expect(text).toContain('api_url = "https://****@host.test/x?****#****"'); - expect(text).not.toContain("pw-secret"); - expect(text).not.toContain("tok-secret"); - }); - - it("redacts a public URL on the context line", () => { - const text = render( - [ - problem({ - path: ["studio", "port"], - location: { line: 3, column: 8, endColumn: 13, exact: true }, - }), - ], - { source: URL_SOURCE }, - ); - - expect(text).toContain('api_url = "https://****@host.test/x?****#****"'); - expect(text).not.toContain("pw-secret"); - expect(text).not.toContain("tok-secret"); - }); - }); - it("keeps the caret inside a windowed long value", () => { const source = `[api]\nschemas = "${"x".repeat(300)}"`; const text = render( [ problem({ path: ["api", "schemas"], - showValue: true, location: { line: 2, column: 11, endColumn: 313, exact: true }, }), ], diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index 40d77e4f4c..428ba3bec0 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -75,7 +75,6 @@ import { displayPath, envReferences, type EnvReference, - isPublicValuePath, nearestEnabledGate, splitRemotePath, withoutFollowOns, @@ -96,11 +95,10 @@ const reportStyle = (stream: ColorStream): ReportStyle => ({ const problem = ( code: ConfigProblem["code"], - fields: Omit & { + fields: Omit & { readonly severity?: ConfigProblem["severity"]; - readonly showValue?: boolean; }, -): ConfigProblem => ({ severity: "error", showValue: false, code, ...fields }); +): ConfigProblem => ({ severity: "error", code, ...fields }); const withRemote = ( problems: ReadonlyArray, @@ -248,7 +246,6 @@ const schemaProblems = ( ...(path.length === 0 ? {} : { path }), reason: detail.reason, ...(detail.help === undefined ? {} : { help: detail.help }), - showValue: issue.code !== "missing_required" && path.length > 0 && isPublicValuePath(path), }); }); }); @@ -278,7 +275,7 @@ function invalidValueProblem(error: CliConfigValueError, file: string): ConfigPr external: true as const, help: `${error.source ?? "the environment"} sets this value, not ${file}; fix or unset it there`, } - : { showValue: isPublicValuePath(path) }), + : {}), }, ); } @@ -300,10 +297,9 @@ function semanticProblem( external: true as const, help: `${describeOrigin(origin)} sets this value, not ${file}; fix or unset it there`, } - : { - ...(issue.help === undefined ? {} : { help: issue.help }), - showValue: code !== "missing_required" && issue.path !== "" && isPublicValuePath(path), - }), + : issue.help === undefined + ? {} + : { help: issue.help }), }); } @@ -358,7 +354,6 @@ function remoteFailureProblem( duplicate?.[1] === undefined ? "use the 20-character ref from your project's dashboard URL" : "give each remote its own project_id", - showValue: true, } : { external: true as const, @@ -466,7 +461,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { problem("schema_version", { reason: error.message, location: locateSchemaDeclaration(source, reportFormat(error.path, error.format)), - showValue: true, }), ], { readSource: true, source }, @@ -520,7 +514,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { "the schema reference is not a Supabase config schema, so the file is read as schema v1", help: "set it to https://supabase.com/schemas/config/v1.json", location: locateSchemaDeclaration(parsed.rawText, format), - showValue: true, }), ); } @@ -737,7 +730,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { path: reference.path, reason: `${reference.name} is not set, so this key gets the literal text "env(${reference.name})"`, help: `set ${reference.name} in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)`, - showValue: true, ...(remote === undefined ? {} : { remote }), }); const isOverridden = (reference: EnvReference, context: string | undefined): boolean => { diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 0283f26043..3f5ceca48d 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -168,7 +168,7 @@ describe("config validate integration", () => { expect(result.stdout).toMatchInlineSnapshot(` " ✗ api.prot unknown key supabase/config.toml:5:1 - 4 │ enabled = true + 4 │ enabled = **** > 5 │ prot = **** │ ^^^^ help: did you mean \`port\`? @@ -176,14 +176,14 @@ describe("config validate integration", () => { ✗ api.max_rows expected a non-negative integer supabase/config.toml:6:12 5 │ prot = **** - > 6 │ max_rows = "lots" - │ ^^^^^^ + > 6 │ max_rows = **** + │ ^^^^ ✗ db.major_version unsupported Postgres version supabase/config.toml:9:17 8 │ [db] - > 9 │ major_version = 16 - │ ^^ + > 9 │ major_version = **** + │ ^^^^ help: use one of 13, 14, 15, 17 ✗ auth.external.github.client_id required when auth.external.github.enabled = true @@ -202,16 +202,16 @@ describe("config validate integration", () => { ⚠ auth.email.smtp.pass SMTP_PASS is not set, so this key gets the literal text "env(SMTP_PASS)" supabase/config.toml:19:8 - 18 │ user = "mailer" - > 19 │ pass = "env(SMTP_PASS)" - │ ^^^^^^^^^^^^^^^^ + 18 │ user = **** + > 19 │ pass = **** + │ ^^^^ help: set SMTP_PASS in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.) ✗ api.max_rows expected a non-negative integer [remote: staging] supabase/config.toml:26:12 25 │ [remotes.staging.api] - > 26 │ max_rows = "many" - │ ^^^^^^ + > 26 │ max_rows = **** + │ ^^^^ ✗ 6 errors, 1 warning in supabase/config.toml Checked remotes: staging @@ -343,14 +343,14 @@ describe("config validate integration", () => { ✗ api.max_rows expected a non-negative integer supabase/config.jsonc:6:17 5 │ "prot": ****, - > 6 │ "max_rows": "lots", - │ ^^^^^^ + > 6 │ "max_rows": ****, + │ ^^^^ ✗ db.major_version unsupported Postgres version supabase/config.jsonc:8:28 7 │ }, - > 8 │ "db": { "major_version": 16 } - │ ^^ + > 8 │ "db": { "major_version": **** } + │ ^^^^ help: use one of 13, 14, 15, 17 ✗ 3 errors in supabase/config.jsonc @@ -1052,13 +1052,13 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("redacts credentials and query strings of a public URL value", () => + it.effect("masks the URL on the context and problem lines", () => Effect.gen(function* () { const url = "https://user:pass-secret@host.test/x?token=tok-secret"; const context = yield* validate( tomlFile(`project_id = "demo"\n[auth]\nsite_url = "${url}"\nbogus = 1\n`), ); - expect(context.stdout).toContain('site_url = "https://****@host.test/x?****"'); + expect(context.stdout).toContain("site_url = ****"); expect(context.stdout).not.toContain("pass-secret"); expect(context.stdout).not.toContain("tok-secret"); diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts index 833249b8a3..cf4e37df40 100644 --- a/apps/cli/src/commands/config/validate/validate.model.ts +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -1,6 +1,4 @@ -import { ENV_CAPTURE_REGEX, isSecretPath, type CliConfigLocation } from "@supabase/config/internal"; - -import { cliConfigRegistry } from "../../../config/cli-config-keys.ts"; +import { ENV_CAPTURE_REGEX, type CliConfigLocation } from "@supabase/config/internal"; type ProblemCode = | "syntax" @@ -34,8 +32,6 @@ export interface ConfigProblem { readonly location?: CliConfigLocation; /** The value comes from the environment, so the config file has no location for it. */ readonly external?: true; - /** The source line may print the value at `location`; every other line is masked. */ - readonly showValue: boolean; } /** Splits a `remotes..…` path into the remote and the path inside it. */ @@ -49,46 +45,6 @@ export function splitRemotePath(path: ReadonlyArray): { : { path }; } -const SEGMENT_SEPARATOR = "\0"; -const ROOT_KEY_PATH = ["db", "root_key"].join(SEGMENT_SEPARATOR); -const SECRET_LOOKING = /secret|pass|token|key|jwt|salt|credential|vault/i; - -const publicLeafPaths: ReadonlySet = new Set( - cliConfigRegistry.keys - .filter((key) => key.document !== false && key.secret !== true) - .map((key) => key.path.split(".").join(SEGMENT_SEPARATOR)), -); - -const publicFamilyLeaves = cliConfigRegistry.families.map((family) => ({ - prefix: family.prefix.split("."), - fields: new Set( - family.fields.filter((field) => field.secret !== true).map((field) => field.name), - ), -})); - -const isPublicFamilyLeaf = (segments: ReadonlyArray): boolean => - publicFamilyLeaves.some( - ({ prefix, fields }) => - segments.length === prefix.length + 2 && - prefix.every((segment, index) => segment === segments[index]) && - fields.has(segments.at(-1) ?? ""), - ); - -/** - * Whether the value at a config key path may be printed: a schema leaf that is neither secret nor - * secret-looking. Everything else, including keys the schema does not know, is masked. - */ -export function isPublicValuePath(path: ReadonlyArray): boolean { - const segments = splitRemotePath(path).path; - const local = segments.join(SEGMENT_SEPARATOR); - return ( - local !== ROOT_KEY_PATH && - !isSecretPath(segments) && - !SECRET_LOOKING.test(segments.at(-1) ?? "") && - (publicLeafPaths.has(local) || isPublicFamilyLeaf(segments)) - ); -} - /** A key path as shown to people; segments that contain dots are indistinguishable from nesting. */ export const displayPath = (path: ReadonlyArray): string => path.join("."); diff --git a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts index e14f18d167..50e00a90ab 100644 --- a/apps/cli/src/commands/config/validate/validate.model.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts @@ -4,7 +4,6 @@ import { type ConfigProblem, dedupeProblems, envReferences, - isPublicValuePath, nearestEnabledGate, splitRemotePath, withoutFollowOns, @@ -14,7 +13,6 @@ import { const problem = (fields: Partial & Pick): ConfigProblem => ({ severity: "error", reason: "bad", - showValue: false, ...fields, }); @@ -33,34 +31,6 @@ describe("splitRemotePath", () => { }); }); -describe("isPublicValuePath", () => { - it.each([ - "db.port", - "db.major_version", - "auth.external.github.client_id", - "remotes.staging.db.port", - ])("shows the value of %s", (path) => { - expect(isPublicValuePath(path.split("."))).toBe(true); - }); - - it.each([ - "db.root_key", - "db.vault.token", - "edge_runtime.secrets.API_KEY", - "auth.external.github.secret", - "auth.email.smtp.pass", - "auth.hook.send_email.secrets", - "api.not_a_key", - ])("masks the value of %s", (path) => { - expect(isPublicValuePath(path.split("."))).toBe(false); - }); - - it("keeps segment boundaries, so a quoted dotted key is not the nested path", () => { - expect(isPublicValuePath(["db.port"])).toBe(false); - expect(isPublicValuePath(["db", "port"])).toBe(true); - }); -}); - describe("dedupeProblems", () => { it("drops a remote problem the base already reports", () => { const base = problem({ diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 26b51ae68a..2a55a8380c 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -39,7 +39,7 @@ artifacts (`./schema.json`, `./project-schema.json`). otherwise-internal registry data (`AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, `ProjectConfigMappingRow`, `ProjectConfigApiAttributes`, `ENV_CAPTURE_REGEX`, and `withoutSafeIntegerBounds` for the CLI's published lock schema), the - JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `scanCliConfigDocument` (a caller-owned scan of raw TOML/JSON/JSONC text whose `locate` finds the line/column of a key or value, `lineKeys` the key path each single-entry line holds and `valueSpans` the span of every scalar or array value, multi-line ones included; `CliConfigLocation` is a position) and `isSecretPath`. It also carries + JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `scanCliConfigDocument` (a caller-owned scan of raw TOML/JSON/JSONC text whose `locate` finds the line/column of a key or value and `valueSpans` the span of every value, multi-line ones included; `CliConfigLocation` is a position). It also carries `supabase config pull`/`diff`'s own support surface (CLI-2064): the format-preserving surgical editor (`applyConfigEdits` and its `ConfigEdit`/`ConfigEditOutcome`/`ConfigEditRefusal`/`ConfigEditRefusalReason`/`ConfigEditValue`/ diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index f57a0a7a17..c15c49d0cf 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -322,58 +322,6 @@ function locateJson( return undefined; } -function tomlLineKeys({ scan, starts }: ScannedToml): ReadonlyMap> { - const found = new Map>(); - if (isScanError(scan)) return found; - for (const token of scan.tokens) { - if (token.kind !== "kv" || token.insideInlineTable) continue; - const line = lineIndexOf(starts, token.start); - if (lineIndexOf(starts, Math.max(token.valueStart, token.valueEnd - 1)) !== line) continue; - found.set(line + 1, token.path); - } - return found; -} - -function jsonLineKeys(source: string): ReadonlyMap> { - const errors: Array = []; - const root = parseTree(source, errors, { - allowTrailingComma: true, - disallowComments: false, - }); - const found = new Map>(); - if (root === undefined || errors.length > 0) return found; - const starts = lineStarts(source); - const keysOnLine = new Map(); - const candidates = new Map>(); - const isFlat = (node: Node) => - node.type !== "object" && - (node.type !== "array" || - (node.children ?? []).every((child) => child.type !== "object" && child.type !== "array")); - const walk = (node: Node, path: ReadonlyArray): void => { - if (node.type === "array") { - node.children?.forEach((child, index) => walk(child, [...path, String(index)])); - return; - } - if (node.type !== "object") return; - for (const property of node.children ?? []) { - const [key, value] = property.children ?? []; - if (key === undefined || value === undefined || typeof key.value !== "string") continue; - const line = lineIndexOf(starts, property.offset); - keysOnLine.set(line, (keysOnLine.get(line) ?? 0) + 1); - const childPath = [...path, key.value]; - const sameLine = - lineIndexOf(starts, Math.max(value.offset, value.offset + value.length - 1)) === line; - if (isFlat(value) && sameLine) candidates.set(line, childPath); - else walk(value, childPath); - } - }; - walk(root, []); - for (const [line, path] of candidates) { - if (keysOnLine.get(line) === 1) found.set(line + 1, path); - } - return found; -} - function tomlValueSpans(source: string, span: Span): ReadonlyArray { if (source[span.start] !== "{") return [span]; const text = source.slice(span.start, span.end); @@ -434,8 +382,6 @@ export interface CliConfigDocumentScan { path: ReadonlyArray, target: "key" | "value", ) => CliConfigLocation | undefined; - /** The key path of every 1-based line that holds one complete key and its value and nothing else. */ - readonly lineKeys: () => ReadonlyMap>; /** The span of every scalar or array value, multi-line ones included; undefined when unparseable. */ readonly valueSpans: () => ReadonlyArray | undefined; } @@ -446,13 +392,11 @@ export function scanCliConfigDocument(text: string, format: ConfigFormat): CliCo const scanned = scanToml(source); return { locate: (path, target) => locateToml(source, path, target, scanned), - lineKeys: () => tomlLineKeys(scanned), valueSpans: () => tomlDocumentValueSpans(source, scanned), }; } return { locate: (path, target) => locateJson(source, path, target), - lineKeys: () => jsonLineKeys(source), valueSpans: () => jsonDocumentValueSpans(source), }; } diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index 2d28d22b89..85d9238d32 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -7,8 +7,6 @@ const locateCliConfigKey = ( path: ReadonlyArray, target: "key" | "value", ) => scanCliConfigDocument(source, format).locate(path, target); -const cliConfigLineKeys = (source: string, format: "toml" | "json") => - scanCliConfigDocument(source, format).lineKeys(); const cliConfigValueSpans = (source: string, format: "toml" | "json") => scanCliConfigDocument(source, format).valueSpans(); import { isSecretPath } from "./lib/secret-paths.ts"; @@ -197,41 +195,6 @@ describe("locateCliConfigKey (spans and inline tables)", () => { }); }); -describe("cliConfigLineKeys", () => { - test("maps each single-line toml key to its full path, skipping inline tables", () => { - const keys = cliConfigLineKeys(toml, "toml"); - expect(keys.get(4)).toEqual(["api", "port"]); - expect(keys.get(5)).toEqual(["api", "tls", "enabled"]); - expect(keys.get(14)).toEqual(["remotes", "staging", "db", "port"]); - expect(keys.has(3)).toBe(false); - expect(keys.has(11)).toBe(false); - }); - - test("leaves out a toml value that spans lines", () => { - const keys = cliConfigLineKeys( - '[auth]\nsite_url = "x"\nadditional_redirect_urls = [\n "a",\n]\n', - "toml", - ); - expect(keys.get(2)).toEqual(["auth", "site_url"]); - expect(keys.has(3)).toBe(false); - }); - - test("maps json lines holding exactly one flat property", () => { - const source = ["{", ' "api": {', ' "port": 1,', ' "a": 1, "b": 2', " }", "}"].join( - "\n", - ); - const keys = cliConfigLineKeys(source, "json"); - expect(keys.get(3)).toEqual(["api", "port"]); - expect(keys.has(2)).toBe(false); - expect(keys.has(4)).toBe(false); - }); - - test("is empty when the text does not parse", () => { - expect(cliConfigLineKeys("[api\nport = 1", "toml").size).toBe(0); - expect(cliConfigLineKeys('{ "api": { "port": 1 ', "json").size).toBe(0); - }); -}); - describe("cliConfigValueSpans", () => { const textOf = (source: string, spans: ReturnType) => (spans ?? []).map((span) => { diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 813b25e3b4..6cd2abe68e 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -531,7 +531,6 @@ describe("src/internal.ts export surface", () => { "decodeMergedCliConfig", "dualScopeProjectConfigPaths", "findUnknownCliConfigKeys", - "isSecretPath", "loadCliConfig", "mapMigrationCheckRules", "mergeParsedCliConfig", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 9af495f864..029aaee3b3 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -53,7 +53,6 @@ export { JsoncSyntaxError, parseJsonc } from "./jsonc.ts"; export { PROVIDER_SIGNUP_ALIASES } from "./lib/provider-signup.ts"; export { CliConfigWriteError } from "./errors.ts"; export { findUnknownCliConfigKeys, type UnknownCliConfigKey } from "./unknown-keys.ts"; -export { isSecretPath } from "./lib/secret-paths.ts"; export { scanCliConfigDocument, type CliConfigDocumentScan, From a01fe583a24931e09b166ce3bdbb98a164c23d58 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 11 Nov 2026 10:26:14 +0000 Subject: [PATCH 07/12] fix(cli): report config validate problems by key and position only Text output now gives each problem's key path, reason, file:line:column and help, and never prints source text or values. Masking source lines could not be made reliable across every TOML, JSON and JSONC encoding of a value, so the code frames and their value-span scanning are removed. ADR 0034 records the rule for config diagnostics. --- apps/cli/docs/supabase/config/validate.md | 4 +- .../commands/config/validate/SIDE_EFFECTS.md | 4 +- .../config/validate/validate.format.ts | 213 +--------------- .../validate/validate.format.unit.test.ts | 230 +----------------- .../config/validate/validate.handler.ts | 3 - .../validate/validate.integration.test.ts | 36 +-- ...config-diagnostics-print-no-source-text.md | 49 ++++ docs/adr/README.md | 1 + packages/config/AGENTS.md | 2 +- packages/config/src/config-locate.ts | 56 +---- .../config/src/config-locate.unit.test.ts | 45 ---- 11 files changed, 65 insertions(+), 578 deletions(-) create mode 100644 docs/adr/0034-config-diagnostics-print-no-source-text.md diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md index 58133811d8..17fcddfc60 100644 --- a/apps/cli/docs/supabase/config/validate.md +++ b/apps/cli/docs/supabase/config/validate.md @@ -1,6 +1,6 @@ # supabase-config-validate -Checks the project config file (`supabase/config.toml`, `config.json` or `config.jsonc`) and reports every problem in one pass, each with its line and a code frame. It never modifies a project file and never contacts the Supabase API. +Checks the project config file (`supabase/config.toml`, `config.json` or `config.jsonc`) and reports every problem in one pass, each with its key path and `file:line:column`. It never modifies a project file and never contacts the Supabase API. ## What is checked @@ -16,7 +16,7 @@ Stages 1 to 4 stop the run when they fail, because later checks cannot be comput ## Secrets -Code frames show keys, headers and positions, never values: every value, on every line it spans, prints as `****`, and the text of comments is masked after its `#` or `//`. JSON output never contains source text or values. A value that comes from a `SUPABASE_*` variable is reported against its key, not a line. +The report shows key paths and `file:line:column`, never source text or values, in text and JSON alike (see [ADR 0034](https://github.com/supabase/cli/blob/develop/docs/adr/0034-config-diagnostics-print-no-source-text.md)). A value that comes from a `SUPABASE_*` variable is reported against its key, not a line. ## `env()` references diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index ef6f121cd1..ea806b5370 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ | Path | Format | When | | ----------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------- | -| `/supabase/config.toml`, `config.json` or `config.jsonc` | TOML/JSONC | always; the same text is reused for the code frames | +| `/supabase/config.toml`, `config.json` or `config.jsonc` | TOML/JSONC | always; read to locate each problem's line and column | | `/supabase/.env*`, `/.env*` | dotenv | always, selected by `SUPABASE_ENV`, to resolve `env()` references and `SUPABASE_*` overrides | | `/supabase/` | text | when an `auth.email.template.*` or notification `content_path` is set | | `/supabase/`, `` | PEM | when `api.tls.enabled` is true | @@ -55,7 +55,7 @@ Each stage reports every problem it finds; a run stops after a stage only when t ### `--output-format text` -stdout is one report: every problem in file order, each as ` ` with `file:line:column`, a code frame, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. Code frames show keys, table headers and positions, never values: every value is masked as `****` on every line it spans, and the text of comments is masked after its `#` or `//`. A document that cannot be parsed is masked line by line in full. Nothing is written to stderr by the command itself. +stdout is one report: every problem in file order, each as ` ` with `file:line:column`, an optional `help:` line and a `[remote: ]` label, then a total. Colour only on a TTY, per `NO_COLOR`/`CLICOLOR`. The report shows key paths and `file:line:column`, never source text or values; see [ADR 0034](../../../../../../docs/adr/0034-config-diagnostics-print-no-source-text.md). Nothing is written to stderr by the command itself. ### `--output-format json` diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index c850f01fd8..764ed511b8 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -1,5 +1,4 @@ import { escapeControlCharacters } from "../../../shared/output/escape-control-characters.ts"; -import { scanCliConfigDocument, type CliConfigLocation } from "@supabase/config/internal"; import { displayPath, type ConfigProblem } from "./validate.model.ts"; @@ -46,18 +45,12 @@ const PAYLOAD_VERSION = 1; export interface ValidateReport { readonly file: string; readonly format: "toml" | "json" | "jsonc"; - readonly source: string; readonly remotes: ReadonlyArray; readonly skippedRemotes: ReadonlyArray<{ readonly name: string; readonly reason: string }>; readonly problems: ReadonlyArray; readonly stoppedAt?: StopStage; } -const MASK = "****"; -const MAX_FRAME_WIDTH = 100; -const CARET_LEAD = 30; -const MAX_VALUE_LINES = 4; - const errorsOf = (report: ValidateReport) => sortedByLocation(report.problems.filter((problem) => problem.severity === "error")); const warningsOf = (report: ValidateReport) => @@ -74,206 +67,6 @@ export function validateSummaryMessage(report: ValidateReport): string { const safe = escapeControlCharacters; -const display = (text: string): string => - [...text] - .map((character) => { - if (character === "\t") return " "; - const code = character.codePointAt(0) ?? 0; - return code < 0x20 || (code >= 0x7f && code <= 0x9f) ? "·" : character; - }) - .join(""); - -interface Span { - readonly column: number; - readonly endColumn?: number; -} - -const COMMENT_MARKER = /#|\/\/|\/\*/; -const COMMENT_LINE = /^\s*(?:#|\/\/|\/\*|\*)[ \t]*/; -const TOML_HEADER = /^\s*\[\[?[^#]*?\]\]?\s*(?=#|$)/; -const JSON_STRUCTURE = /^\s*(?:"(?:[^"\\]|\\.)*"\s*:\s*)?[{[]?[}\]]*\s*,?\s*$/; - -interface MaskedLine { - readonly text: string; - readonly map: (index: number) => number; -} - -function maskLine(text: string, spans: ReadonlyArray): MaskedLine { - const ranges = spans - .map((span) => ({ - start: Math.min(span.column - 1, text.length), - end: span.endColumn === undefined ? text.length : Math.min(span.endColumn - 1, text.length), - })) - .sort((left, right) => left.start - right.start); - let out = ""; - let position = 0; - const applied: Array<{ start: number; end: number; outStart: number }> = []; - for (const range of ranges) { - if (range.end <= position) continue; - const start = Math.max(range.start, position); - out += text.slice(position, start); - applied.push({ start, end: range.end, outStart: out.length }); - out += MASK; - position = range.end; - } - out += text.slice(position); - return { - text: out, - map: (index) => { - let shift = 0; - for (const range of applied) { - if (index < range.start) break; - if (index < range.end) return range.outStart; - shift += MASK.length - (range.end - range.start); - } - return index + shift; - }, - }; -} - -/** The text of every comment in `gap`, which starts at 0-based `offset` of its line. */ -function commentSpans(gap: string, offset: number): ReadonlyArray { - const index = gap.search(COMMENT_MARKER); - if (index < 0) return []; - const block = gap.startsWith("/*", index); - const markerLength = gap.startsWith("#", index) ? 1 : 2; - const close = block ? gap.indexOf("*/", index + markerLength) : -1; - const end = close < 0 ? gap.length : close; - let start = index + markerLength; - while (start < end && (gap[start] === " " || gap[start] === "\t")) start += 1; - const here: ReadonlyArray = - start < end ? [{ column: offset + start + 1, endColumn: offset + end + 1 }] : []; - return close < 0 ? here : [...here, ...commentSpans(gap.slice(close + 2), offset + close + 2)]; -} - -function unvaluedSpans(text: string, format: SourceView["format"]): ReadonlyArray { - if (format === "toml") { - const header = TOML_HEADER.exec(text); - if (header !== null) return commentSpans(text.slice(header[0].length), header[0].length); - } else if (JSON_STRUCTURE.test(text)) { - return []; - } - const marker = COMMENT_LINE.exec(text); - return [{ column: (marker?.[0].length ?? text.length - text.trimStart().length) + 1 }]; -} - -/** - * A source line with every value, and the text of every comment, masked. Keys, headers and - * punctuation stay. Source that cannot be parsed is masked in full. - */ -function maskedAt({ lines, valuesByLine, format }: SourceView, lineNumber: number): MaskedLine { - const text = lines[lineNumber - 1] ?? ""; - const indent = text.length - text.trimStart().length; - if (text.trim() === "") return maskLine(text, []); - if (valuesByLine === undefined) return maskLine(text, [{ column: indent + 1 }]); - - const values = valuesByLine.get(lineNumber) ?? []; - if (values.length === 0) return maskLine(text, unvaluedSpans(text, format)); - - const spans: Array = values - .map((value) => { - const column = value.line === lineNumber ? value.column : indent + 1; - const endColumn = (value.endLine ?? value.line) === lineNumber ? value.endColumn : undefined; - return endColumn === undefined ? { column } : { column, endColumn }; - }) - .sort((left, right) => left.column - right.column); - const comments: Array = []; - let position = 0; - for (const span of spans) { - comments.push(...commentSpans(text.slice(position, span.column - 1), position)); - position = span.endColumn === undefined ? text.length : span.endColumn - 1; - } - comments.push(...commentSpans(text.slice(position), position)); - return maskLine(text, [...spans, ...comments]); -} - -function windowAround(text: string, caretOffset: number): { text: string; caretOffset: number } { - if (text.length <= MAX_FRAME_WIDTH) return { text, caretOffset }; - const from = Math.max(0, caretOffset - CARET_LEAD); - const slice = text.slice(from, from + MAX_FRAME_WIDTH); - const head = from > 0 ? "…" : ""; - const tail = from + MAX_FRAME_WIDTH < text.length ? "…" : ""; - return { text: `${head}${slice}${tail}`, caretOffset: caretOffset - from + head.length }; -} - -interface SourceView { - readonly lines: ReadonlyArray; - readonly format: "toml" | "json"; - /** The value spans touching each 1-based line; undefined when the source cannot be parsed. */ - readonly valuesByLine: ReadonlyMap> | undefined; -} - -function sourceView(report: ValidateReport): SourceView { - const lines = report.source.replace(/^\uFEFF/, "").split(/\r?\n/); - const format = report.format === "toml" ? "toml" : "json"; - const values = scanCliConfigDocument(report.source, format).valueSpans(); - if (values === undefined) return { lines, format, valuesByLine: undefined }; - const valuesByLine = new Map>(); - for (const value of values) { - for (let line = value.line; line <= (value.endLine ?? value.line); line++) { - valuesByLine.set(line, [...(valuesByLine.get(line) ?? []), value]); - } - } - return { lines, format, valuesByLine }; -} - -function frameLines( - problem: ConfigProblem, - view: SourceView, - style: ReportStyle, -): ReadonlyArray { - const { lines } = view; - const location = problem.location; - const first = location === undefined ? undefined : lines[location.line - 1]; - if (location === undefined || first === undefined) return []; - - const lastLine = Math.min( - location.endLine ?? location.line, - location.line + MAX_VALUE_LINES - 1, - lines.length, - ); - const width = String(lastLine).length; - const gutter = (label: string) => `${label.padStart(width)} │`; - const out: Array = []; - - if (location.line > 1) { - const before = maskedAt(view, location.line - 1).text; - const shown = display(before).trimEnd(); - const clipped = shown.length > MAX_FRAME_WIDTH ? `${shown.slice(0, MAX_FRAME_WIDTH)}…` : shown; - out.push( - ` ${style.dim(`${gutter(String(location.line - 1))}${clipped === "" ? "" : " "}${clipped}`)}`, - ); - } - - const current = maskedAt(view, location.line); - const start = current.map(location.column - 1); - const singleLine = location.endLine === undefined || location.endLine === location.line; - const end = - singleLine && location.endColumn !== undefined - ? current.map(location.endColumn - 1) - : singleLine - ? start + 1 - : current.text.length; - const caretOffset = display(current.text.slice(0, start)).length; - const windowed = windowAround(display(current.text).trimEnd(), caretOffset); - const visible = - windowed.text.length - windowed.caretOffset - (windowed.text.endsWith("…") ? 1 : 0); - const caretLength = Math.max( - 1, - Math.min(display(current.text.slice(start, end)).length, visible), - ); - out.push(` ${style.red(">")} ${style.dim(gutter(String(location.line)))} ${windowed.text}`); - out.push( - ` ${style.dim(gutter(""))} ${" ".repeat(windowed.caretOffset)}${style.red("^".repeat(caretLength))}`, - ); - for (let line = location.line + 1; line <= lastLine; line++) { - const text = display(maskedAt(view, line).text).trimEnd(); - const clipped = text.length > MAX_FRAME_WIDTH ? `${text.slice(0, MAX_FRAME_WIDTH)}…` : text; - out.push(` ${style.red(">")} ${style.dim(gutter(String(line)))} ${clipped}`); - } - return out; -} - const locationLabel = (report: ValidateReport, problem: ConfigProblem): string | undefined => problem.location === undefined ? undefined @@ -296,7 +89,6 @@ function sortedByLocation(problems: ReadonlyArray): ReadonlyArray function renderProblem( report: ValidateReport, problem: ConfigProblem, - source: SourceView, style: ReportStyle, ): ReadonlyArray { const glyph = problem.severity === "error" ? style.red("✗") : style.yellow("⚠"); @@ -308,7 +100,7 @@ function renderProblem( const out = [` ${glyph} ${style.bold(subject)} ${safe(problem.reason)}${remote}`]; const label = locationLabel(report, problem); if (label !== undefined) { - out.push(` ${style.dim(label)}`, ...frameLines(problem, source, style)); + out.push(` ${style.dim(label)}`); } if (problem.help !== undefined) { out.push(` ${style.green("help:")} ${safe(problem.help)}`); @@ -329,12 +121,11 @@ export function validateReport( ): string { const errors = errorsOf(report); const warnings = warningsOf(report); - const source = sourceView(report); const file = safe(report.file); const out: Array = []; for (const problem of sortedByLocation(report.problems)) { - out.push(...renderProblem(report, problem, source, style), ""); + out.push(...renderProblem(report, problem, style), ""); } if (errors.length === 0) { diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index 81527da229..f4bee48dbc 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -9,14 +9,7 @@ import { } from "./validate.format.ts"; import type { ConfigProblem } from "./validate.model.ts"; -const SOURCE = [ - 'project_id = "demo"', - "[api]", - "prot = 1", - 'secret = "hunter2"', - "port = 5", - 'cfg = { token = "hunter2", n = "bad" }', -].join("\n"); +const SOURCE = ['project_id = "demo"', "[api]", "prot = 1", 'secret = "hunter2"'].join("\n"); const problem = (fields: Partial): ConfigProblem => ({ code: "invalid_type", @@ -29,7 +22,6 @@ const report = (problems: ReadonlyArray, extra: Partial, extra: Partial { - it("draws a frame with the previous line, a caret under the span and the help line", () => { + it("renders a located problem as three lines and prints no source text", () => { const text = render([ problem({ code: "unknown_key", @@ -55,230 +47,20 @@ describe("validateReport", () => { [ " ✗ api.prot unknown key", " supabase/config.toml:3:1", - " 2 │ [api]", - " > 3 │ prot = ****", - " │ ^^^^", " help: did you mean `port`?", "", "✗ 1 error in supabase/config.toml", "", ].join("\n"), ); - }); - - it("masks every configured value by default, including the context line", () => { - const text = render([ - problem({ - path: ["api", "port"], - location: { line: 5, column: 8, endColumn: 9, exact: true }, - }), - ]); - - expect(text).toContain(" > 5 │ port = ****"); - expect(text).not.toContain("hunter2"); - }); - - it("masks a public scalar and keeps its key", () => { - const text = render( - [ - problem({ - path: ["api", "enabled"], - location: { line: 2, column: 11, endColumn: 15, exact: true }, - }), - ], - { source: "[api]\nenabled = true" }, - ); - - expect(text).toContain(" > 2 │ enabled = ****"); - expect(text).not.toContain("true"); - }); - - it("masks every value in an inline table, keeping keys and punctuation", () => { - const text = render([ - problem({ - path: ["api", "cfg", "n"], - location: { line: 6, column: 32, endColumn: 37, exact: true }, - }), - ]); - - expect(text).toContain("cfg = { token = ****, n = **** }"); - expect(text).not.toContain("hunter2"); - }); - - it("masks a url, a nested table and an array alike", () => { - const text = render([problem({ path: ["x"], location: { line: 4, column: 1, exact: true } })], { - source: [ - 'url = "https://user:pw\'secret@host.test/x?token=tok-secret#top"', - 'tables = [{ secret = "hunter2" }]', - 'schemas = ["a", "b"]', - "k = 1", - ].join("\n"), - }); - - expect(text).toContain(" > 4 │ k = ****"); - expect(text).toContain("3 │ schemas = ****"); - expect(text).not.toMatch(/hunter2|secret|host\.test/); - }); - - it("masks the value on the context line", () => { - const text = render([ - problem({ - path: ["api", "cfg"], - location: { line: 6, column: 1, endColumn: 4, exact: true }, - }), - ]); - - expect(text).toContain("5 │ port = ****"); - }); - - it("masks the text of a TOML comment and keeps its marker", () => { - const text = render( - [problem({ path: ["x"], location: { line: 3, column: 1, endColumn: 2, exact: true } })], - { source: '# secret = "hunter2"\n[api] # token hunter2\nk = 1 # note hunter2' }, - ); - - expect(text).toContain("2 │ [api] # ****"); - expect(text).toContain("> 3 │ k = **** # ****"); - expect(text).not.toContain("hunter2"); - }); - - it("masks commented-out lines before the problem", () => { - const text = render([problem({ path: ["x"], location: { line: 2, column: 1, exact: true } })], { - source: '# token = "hunter2"\nk = 1', - }); - - expect(text).toContain("1 │ # ****"); - expect(text).not.toContain("hunter2"); - }); - - it("masks JSONC values and comments but keeps keys and structure", () => { - const source = [ - "{", - ' "api": {', - ' // "token": "hunter2"', - ' "enabled": true, /* hunter2 */ "n": 1 // hunter2', - " }", - "}", - ].join("\n"); - const text = render( - [ - problem({ - path: ["api", "n"], - location: { line: 4, column: 38, endColumn: 39, exact: true }, - }), - ], - { format: "jsonc", source }, - ); - - expect(text).toContain("3 │ // ****"); - expect(text).toContain('> 4 │ "enabled": ****, /* *****/ "n": **** // ****'); - expect(text).not.toContain("hunter2"); - }); - - it("masks the interior of a JSONC block comment", () => { - const source = '{\n /*\n "token": "hunter2"\n */\n "k": 1\n}'; - const text = render([problem({ path: ["k"], location: { line: 4, column: 3, exact: true } })], { - format: "jsonc", - source, - }); - + for (const line of SOURCE.split("\n")) expect(text).not.toContain(line); expect(text).not.toContain("hunter2"); }); - it("underlines the masked value, not the characters it replaced", () => { - const text = render( - [ - problem({ - path: ["api", "max_rows"], - location: { line: 2, column: 12, endColumn: 18, exact: true }, - }), - ], - { source: '[api]\nmax_rows = "lots"' }, - ); - - expect(text).toContain(" > 2 │ max_rows = ****\n │ ^^^^"); - }); - - describe("multi-line secrets", () => { - const SECRET_SOURCE = [ - "[auth.hook.send_email]", - 'secrets = """', - " # hunter2-do-not-print", - ' c2VjcmV0cGFzc3dvcmQ=="""', - "bogus = 1", - ].join("\n"); - - it("masks comment-looking and padded-base64 continuation lines on the problem's range", () => { - const text = render( - [ - problem({ - path: ["auth", "hook", "send_email", "secrets"], - location: { line: 2, column: 11, endLine: 4, endColumn: 29, exact: true }, - }), - ], - { source: SECRET_SOURCE }, - ); - - expect(text).not.toContain("hunter2"); - expect(text).not.toContain("c2Vj"); - expect(text).not.toContain("=="); - expect(text).toContain("secrets = ****"); - }); - - it("masks the closing line of a multi-line secret used as the context line", () => { - const text = render( - [ - problem({ - path: ["auth", "hook", "send_email", "bogus"], - location: { line: 5, column: 1, exact: true }, - }), - ], - { source: SECRET_SOURCE }, - ); - - expect(text).not.toContain("c2Vj"); - expect(text).not.toContain("=="); - }); - - it("masks every line of a multi-line array of secrets in JSON", () => { - const text = render( - [problem({ path: ["x"], location: { line: 2, column: 3, exact: true } })], - { - format: "json", - source: '{\n "secrets": [\n "hunter2",\n // note\n "other"\n ]\n}', - }, - ); - - expect(text).not.toContain("hunter2"); - expect(text).not.toContain("other"); - }); - - it("masks every non-blank line when the source cannot be parsed", () => { - const text = render( - [problem({ code: "syntax", location: { line: 3, column: 1, exact: true } })], - { source: 'secrets = """\n# hunter2-do-not-print\nhost = "x' }, - ); - - expect(text).not.toContain("hunter2"); - expect(text).not.toContain("host"); - }); - }); - - it("keeps the caret inside a windowed long value", () => { - const source = `[api]\nschemas = "${"x".repeat(300)}"`; - const text = render( - [ - problem({ - path: ["api", "schemas"], - location: { line: 2, column: 11, endColumn: 313, exact: true }, - }), - ], - { source }, - ); - const carets = text.split("\n").find((line) => line.includes("^")) ?? ""; - const frame = text.split("\n").find((line) => line.startsWith(" > 2")) ?? ""; + it("renders a problem without a location without a position line", () => { + const text = render([problem({ path: ["api", "port"], help: "fix it" })]); - expect(carets.trimEnd().length).toBeLessThanOrEqual(frame.length); + expect(text).toContain(" ✗ api.port bad\n help: fix it\n"); }); it("labels remote problems and lists checked and skipped remotes", () => { diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index 428ba3bec0..831f978e52 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -405,7 +405,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { return { file: display, format: reportFormat(display, format), - source, remotes: [], skippedRemotes: [], stoppedAt, @@ -603,7 +602,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { const report = { file, format, - source: parsed.rawText, remotes: [], skippedRemotes: [], stoppedAt: base.stop, @@ -761,7 +759,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { return yield* emit({ file, format, - source: parsed.rawText, remotes: checkedRemotes, skippedRemotes, ...(remoteStop === undefined ? {} : { stoppedAt: remoteStop }), diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 3f5ceca48d..d0538144f9 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -168,50 +168,29 @@ describe("config validate integration", () => { expect(result.stdout).toMatchInlineSnapshot(` " ✗ api.prot unknown key supabase/config.toml:5:1 - 4 │ enabled = **** - > 5 │ prot = **** - │ ^^^^ help: did you mean \`port\`? ✗ api.max_rows expected a non-negative integer supabase/config.toml:6:12 - 5 │ prot = **** - > 6 │ max_rows = **** - │ ^^^^ ✗ db.major_version unsupported Postgres version supabase/config.toml:9:17 - 8 │ [db] - > 9 │ major_version = **** - │ ^^^^ help: use one of 13, 14, 15, 17 ✗ auth.external.github.client_id required when auth.external.github.enabled = true supabase/config.toml:11:2 - 10 │ - > 11 │ [auth.external.github] - │ ^^^^^^^^^^^^^^^^^^^^ help: set client_id, or set enabled = false ✗ auth.external.github.secret required when auth.external.github.enabled = true supabase/config.toml:11:2 - 10 │ - > 11 │ [auth.external.github] - │ ^^^^^^^^^^^^^^^^^^^^ help: set secret, or set enabled = false ⚠ auth.email.smtp.pass SMTP_PASS is not set, so this key gets the literal text "env(SMTP_PASS)" supabase/config.toml:19:8 - 18 │ user = **** - > 19 │ pass = **** - │ ^^^^ help: set SMTP_PASS in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.) ✗ api.max_rows expected a non-negative integer [remote: staging] supabase/config.toml:26:12 - 25 │ [remotes.staging.api] - > 26 │ max_rows = **** - │ ^^^^ ✗ 6 errors, 1 warning in supabase/config.toml Checked remotes: staging @@ -335,22 +314,13 @@ describe("config validate integration", () => { expect(result.stdout).toMatchInlineSnapshot(` " ✗ api.prot unknown key supabase/config.jsonc:5:5 - 4 │ "api": { - > 5 │ "prot": ****, - │ ^^^^^^ help: did you mean \`port\`? ✗ api.max_rows expected a non-negative integer supabase/config.jsonc:6:17 - 5 │ "prot": ****, - > 6 │ "max_rows": ****, - │ ^^^^ ✗ db.major_version unsupported Postgres version supabase/config.jsonc:8:28 - 7 │ }, - > 8 │ "db": { "major_version": **** } - │ ^^^^ help: use one of 13, 14, 15, 17 ✗ 3 errors in supabase/config.jsonc @@ -974,13 +944,12 @@ describe("config validate integration", () => { const leaks = (result: Parameters[0]) => expect(reportedTexts(result)).not.toContain(SECRET); - it.effect("masks every line of a document with a syntax error", () => + it.effect("prints no source text for a document with a syntax error", () => Effect.gen(function* () { const result = yield* validate( tomlFile(`project_id = "demo"\n[auth.email.smtp]\npass = "${SECRET}"\nhost = "x\n`), ); - expect(result.stdout).toContain("> 4 │ ****"); leaks(result); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1004,7 +973,6 @@ describe("config validate integration", () => { `project_id = "demo"\n[auth.email.smtp]\nenabled = true\nhost = "h"\nport = 25\nuser = "u"\nadmin_email = "a@b.dev"\npasss = "${SECRET}"\n`, ), ); - expect(text.stdout).toContain("passs = ****"); leaks(text); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1035,7 +1003,6 @@ describe("config validate integration", () => { ); expect(result.stdout).toContain("auth.hook.send_email.bogus"); - expect(result.stdout).toContain("bogus = ****"); leaks(result); expect(result.stdout).not.toContain(padded.slice(0, 8)); }).pipe(Effect.provide(BunServices.layer)), @@ -1058,7 +1025,6 @@ describe("config validate integration", () => { const context = yield* validate( tomlFile(`project_id = "demo"\n[auth]\nsite_url = "${url}"\nbogus = 1\n`), ); - expect(context.stdout).toContain("site_url = ****"); expect(context.stdout).not.toContain("pass-secret"); expect(context.stdout).not.toContain("tok-secret"); diff --git a/docs/adr/0034-config-diagnostics-print-no-source-text.md b/docs/adr/0034-config-diagnostics-print-no-source-text.md new file mode 100644 index 0000000000..1225eb7ce3 --- /dev/null +++ b/docs/adr/0034-config-diagnostics-print-no-source-text.md @@ -0,0 +1,49 @@ +# 0034. Config diagnostics print no source text + +**Status**: accepted +**Date**: 2026-11-11 + +## Problem Statement + +`supabase config validate` reports every problem in a config file, and a report that quotes the +offending line is easier to act on. A config file holds secrets, though: JWT secrets, SMTP and +provider credentials, URLs with embedded passwords or tokens. Validation output lands in +terminals, CI logs, screen shares and pasted bug reports, so anything it prints should be treated +as public. + +Showing source lines while masking the secrets in them proved unreliable. Each masking rule +(a list of keys safe to print, masking by parsed value span, URL redaction, printing only plain +single-line scalars) was followed by a new bypass: multi-line strings, inline tables, string +escapes, quoted keys containing dots, and URLs the redaction pattern did not recognise. Masking +every value kept the frame but removed the information that made it useful. + +## Decision + +- Config diagnostics identify a problem by key path and `file:line:column`, and never print + source text or configured values, in any output format. +- The reason and help lines are written from the schema and the problem's kind, never from the + value. An expected type or the allowed choices may be named; the value that was found may not. +- A problem whose value came from the environment names the variable, not its value. +- This applies to `supabase config validate` and to any later command that reports problems in + a config file. + +## Rationale + +- Key paths and positions are enough to find and fix a problem: an editor or `sed -n` gets the + user to the line, which they can read on their own machine. +- Never printing source text cannot leak, however the file is written. Masking source text has + to understand every way TOML, JSON and JSONC can encode a value, and has to keep up with them. +- Structured output (`--output-format json`) already carried only codes, paths and positions, so + text and JSON output now follow the same rule. + +## Consequences + +### Positive + +- Validation output is safe to paste into an issue or keep in a CI log. +- The formatter no longer depends on a value-span scanner or a list of keys safe to print. + +### Negative + +- A report does not show the offending value, so a user whose problem is the value itself + (a wrong type or an unknown enum choice) opens the file to see it. diff --git a/docs/adr/README.md b/docs/adr/README.md index a50ae3f1bc..00f76f88c9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -75,6 +75,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0031 | [Go CLI Removal](0031-go-cli-removal.md) | accepted | | 0032 | [Config Value Precedence](0032-config-value-precedence.md) | proposed | | 0033 | [Target Selection and Hosted-Mutation Consent](0033-target-selection-and-hosted-mutation-consent.md) | proposed | +| 0034 | [Config Diagnostics Print No Source Text](0034-config-diagnostics-print-no-source-text.md) | accepted | ## Template diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 2a55a8380c..68c82f3314 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -39,7 +39,7 @@ artifacts (`./schema.json`, `./project-schema.json`). otherwise-internal registry data (`AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, `ProjectConfigMappingRow`, `ProjectConfigApiAttributes`, `ENV_CAPTURE_REGEX`, and `withoutSafeIntegerBounds` for the CLI's published lock schema), the - JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `scanCliConfigDocument` (a caller-owned scan of raw TOML/JSON/JSONC text whose `locate` finds the line/column of a key or value and `valueSpans` the span of every value, multi-line ones included; `CliConfigLocation` is a position). It also carries + JSONC parser (`parseJsonc`, `JsoncSyntaxError`) and `cliConfigFormatForPath`, `findUnknownCliConfigKeys` (schema-derived unknown-key paths of a raw document, each with the keys its parent table accepts), `scanCliConfigDocument` (a caller-owned scan of raw TOML/JSON/JSONC text whose `locate` finds the line/column of a key or value; `CliConfigLocation` is a position). It also carries `supabase config pull`/`diff`'s own support surface (CLI-2064): the format-preserving surgical editor (`applyConfigEdits` and its `ConfigEdit`/`ConfigEditOutcome`/`ConfigEditRefusal`/`ConfigEditRefusalReason`/`ConfigEditValue`/ diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index c15c49d0cf..fd710eb2b4 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -1,4 +1,4 @@ -import { parseTree, type Node, type ParseError } from "jsonc-parser"; +import { parseTree, type Node } from "jsonc-parser"; import { scanTomlDocument, type TomlHeaderToken, @@ -322,56 +322,6 @@ function locateJson( return undefined; } -function tomlValueSpans(source: string, span: Span): ReadonlyArray { - if (source[span.start] !== "{") return [span]; - const text = source.slice(span.start, span.end); - if (text.includes('"""') || text.includes("'''")) return [span]; - const entries = inlineTableEntries(source, span); - return entries === undefined - ? [span] - : entries.flatMap((entry) => tomlValueSpans(source, entry.value)); -} - -function jsonValueSpans(root: Node): ReadonlyArray { - const found: Array = []; - const walk = (node: Node): void => { - if (node.type === "object") { - for (const property of node.children ?? []) { - const value = property.children?.[1]; - if (value !== undefined) walk(value); - } - } else if (node.type === "array") { - for (const child of node.children ?? []) walk(child); - } else { - found.push({ start: node.offset, end: node.offset + node.length }); - } - }; - walk(root); - return found; -} - -function tomlDocumentValueSpans( - source: string, - { scan, starts }: ScannedToml, -): ReadonlyArray | undefined { - if (isScanError(scan)) return undefined; - return scan.tokens - .filter((token): token is TomlKeyToken => token.kind === "kv") - .flatMap((token) => tomlValueSpans(source, { start: token.valueStart, end: token.valueEnd })) - .filter((span) => span.end > span.start) - .map((span) => toLocation(starts, span, true)); -} - -function jsonDocumentValueSpans(source: string): ReadonlyArray | undefined { - const errors: Array = []; - const root = parseTree(source, errors, { allowTrailingComma: true, disallowComments: false }); - if (root === undefined || errors.length > 0) return undefined; - const starts = lineStarts(source); - return jsonValueSpans(root) - .filter((span) => span.end > span.start) - .map((span) => toLocation(starts, span, true)); -} - /** One scan of raw config text, owned by the caller so the text is not retained beyond it. */ export interface CliConfigDocumentScan { /** @@ -382,8 +332,6 @@ export interface CliConfigDocumentScan { path: ReadonlyArray, target: "key" | "value", ) => CliConfigLocation | undefined; - /** The span of every scalar or array value, multi-line ones included; undefined when unparseable. */ - readonly valueSpans: () => ReadonlyArray | undefined; } export function scanCliConfigDocument(text: string, format: ConfigFormat): CliConfigDocumentScan { @@ -392,11 +340,9 @@ export function scanCliConfigDocument(text: string, format: ConfigFormat): CliCo const scanned = scanToml(source); return { locate: (path, target) => locateToml(source, path, target, scanned), - valueSpans: () => tomlDocumentValueSpans(source, scanned), }; } return { locate: (path, target) => locateJson(source, path, target), - valueSpans: () => jsonDocumentValueSpans(source), }; } diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index 85d9238d32..efcf40e374 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -7,8 +7,6 @@ const locateCliConfigKey = ( path: ReadonlyArray, target: "key" | "value", ) => scanCliConfigDocument(source, format).locate(path, target); -const cliConfigValueSpans = (source: string, format: "toml" | "json") => - scanCliConfigDocument(source, format).valueSpans(); import { isSecretPath } from "./lib/secret-paths.ts"; const toml = [ @@ -195,49 +193,6 @@ describe("locateCliConfigKey (spans and inline tables)", () => { }); }); -describe("cliConfigValueSpans", () => { - const textOf = (source: string, spans: ReturnType) => - (spans ?? []).map((span) => { - const lines = source.split("\n"); - const first = lines[span.line - 1] ?? ""; - if (span.endLine === undefined || span.endLine === span.line) { - return first.slice(span.column - 1, (span.endColumn ?? 1) - 1); - } - return [ - first.slice(span.column - 1), - ...lines.slice(span.line, span.endLine - 1), - (lines[span.endLine - 1] ?? "").slice(0, (span.endColumn ?? 1) - 1), - ].join("\n"); - }); - - test("covers a multi-line toml string and each value of an inline table", () => { - const source = 'a = """\n# x\nb=="""\nc = { d = 1, e = "two" }\nf = [1, 2]\n'; - expect(textOf(source, cliConfigValueSpans(source, "toml"))).toEqual([ - '"""\n# x\nb=="""', - "1", - '"two"', - "[1, 2]", - ]); - }); - - test("covers each json scalar, including those of a multi-line array", () => { - const source = '{\n "a": [\n "x",\n 2\n ],\n "b": { "c": true }\n}'; - expect(textOf(source, cliConfigValueSpans(source, "json"))).toEqual(['"x"', "2", "true"]); - }); - - test("covers an inline table holding a triple-quoted string as one span", () => { - const source = 'auth = { jwt_secret = """foo", hunter2 = "x", tail = "bar""" }\n'; - expect(textOf(source, cliConfigValueSpans(source, "toml"))).toEqual([ - '{ jwt_secret = """foo", hunter2 = "x", tail = "bar""" }', - ]); - }); - - test("is undefined when the text does not parse", () => { - expect(cliConfigValueSpans('a = """\nx', "toml")).toBeUndefined(); - expect(cliConfigValueSpans('{ "a": ', "json")).toBeUndefined(); - }); -}); - describe("locateCliConfigKey inline-table keys", () => { test("matches a quoted key whose escapes decode to the path segment", () => { const source = 'api = { "po\\u0072t" = "bad" }\n'; From 25407c1beed62ac221bba101760981e9ceca5d25 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 11 Nov 2026 10:59:03 +0000 Subject: [PATCH 08/12] fix(cli): tighten config validate remote overrides, key paths and locating - A remote that replaces an unset base env() with a table reports its own errors. - Value errors carry key segments, so names containing dots locate correctly. - The TOML scanner recognises triple-quoted strings inside inline tables. - JSON documents are parsed once per scan handle. - A present key with an invalid value is no longer also reported as missing. - The docs example shows no source text, matching ADR 0034. --- apps/cli/docs/supabase/config/validate.md | 3 -- .../config/validate/validate.handler.ts | 20 +++++--- .../validate/validate.integration.test.ts | 47 +++++++++++++++++++ apps/cli/src/config/cli-config-key.ts | 9 +++- .../cli/src/config/cli-config-values.layer.ts | 3 ++ apps/cli/src/config/cli-config.errors.ts | 2 + packages/config/src/config-locate.ts | 32 ++++++++++--- .../config/src/config-locate.unit.test.ts | 8 ++++ 8 files changed, 107 insertions(+), 17 deletions(-) diff --git a/apps/cli/docs/supabase/config/validate.md b/apps/cli/docs/supabase/config/validate.md index 17fcddfc60..beaf9e1e11 100644 --- a/apps/cli/docs/supabase/config/validate.md +++ b/apps/cli/docs/supabase/config/validate.md @@ -31,9 +31,6 @@ An unset `env(NAME)` is a warning when the section it sits in is enabled (the ne ```text ✗ db.major_version unsupported Postgres version supabase/config.toml:9:17 - 8 │ [db] - > 9 │ major_version = 16 - │ ^^ help: use one of 13, 14, 15, 17 ✗ 1 error in supabase/config.toml diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index 831f978e52..36973c297a 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -230,10 +230,16 @@ function unknownKeyProblems(parsed: ParsedCliConfigDocument): ReadonlyArray, + pruned: ReadonlyArray> = [], ): ReadonlyArray => failures.flatMap((failure) => { - const flat = flattenSchemaIssues(failure.cause); - if (flat.length === 0) { + const all = flattenSchemaIssues(failure.cause); + const flat = all.filter( + (issue) => + issue.code !== "missing_required" || + !pruned.some((path) => path.join("\0") === issue.path.join("\0")), + ); + if (all.length === 0) { return [problem("invalid_value", { reason: "does not match the config schema" })]; } return flat.map((issue) => { @@ -259,7 +265,7 @@ function invalidValueProblem(error: CliConfigValueError, file: string): ConfigPr : error.issues !== undefined && error.issues.length > 0 ? error.issues.join("; ") : "invalid value"; - const path = error.path.split("."); + const path = error.segments ?? error.path.split("."); return problem( error.kind === "enum" ? "invalid_enum" @@ -555,7 +561,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { const resolved = result.success; if (resolved.pruneLimitReached) { return { - problems: withRemote(schemaProblems(resolved.schemaFailures), remote), + problems: withRemote(schemaProblems(resolved.schemaFailures, resolved.pruned), remote), resolved, stop: "schema_limit", } satisfies PassResult; @@ -568,14 +574,14 @@ export const configValidate = Effect.fn("config.validate")(function* () { ); const removedPaths = [ ...resolved.pruned, - ...resolved.invalid.map((error) => error.path.split(".")), + ...resolved.invalid.map((error) => error.segments ?? error.path.split(".")), ]; const describeOrigin = (origin: CliConfigKeyOrigin) => describeCliConfigOrigin(origin, { workdir, path: pathService }); const passed: PassResult = { problems: withRemote( [ - ...schemaProblems(resolved.schemaFailures), + ...schemaProblems(resolved.schemaFailures, resolved.pruned), ...resolved.invalid.map((error) => invalidValueProblem(error, file)), ...withoutFollowOns( semantic.map((issue) => @@ -683,7 +689,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { isDocumentRecord(rawRemotes) && path.some((_, index) => { const value = getDocumentValue(rawRemotes[remote], path.slice(0, index + 1)); - return value !== undefined && !isDocumentRecord(value); + return value !== undefined && (index === path.length - 1 || !isDocumentRecord(value)); }); const inheritedBy = (reference: EnvReference, remote: string): boolean => reference.remote === undefined && !remoteOverrides(remote, reference.path); diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index d0538144f9..be826bc741 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -832,6 +832,47 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("reports a remote's own error when it replaces an unset env() value with a table", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[api]\nmax_rows = "env(UNSET_ROWS)"\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.api.max_rows]\nx = 1\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toContainEqual( + expect.objectContaining({ remote: "prod", key: "remotes.prod.api.max_rows" }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("locates a codec failure under a provider name that contains a dot", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[auth.external."my.idp"]\nenabled = "yes"\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toContainEqual( + expect.objectContaining({ line: 3 }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reports a present key with a bad value once, not also as missing", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[local.services.rest]\nversion = "tok-secret-orioledb"\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "invalid_value", key: "local.services.rest.version", line: 3 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("locates a remote without project_id inside that remote, not on the base key", () => Effect.gen(function* () { const result = yield* validate( @@ -1110,6 +1151,12 @@ describe("config validate integration", () => { "literal dotted key that looks like a public path", tomlFile(`project_id = "demo"\n"db.port" = "hunter2"\nbogus = 1\n`), ], + [ + "value-bearing schema filter message", + tomlFile( + 'project_id = "demo"\n[local.services.auth]\nversion = "hunter2"\n[local.services.rest]\nversion = "tok-secret-orioledb"\n', + ), + ], [ "schema reference carrying credentials", tomlFile( diff --git a/apps/cli/src/config/cli-config-key.ts b/apps/cli/src/config/cli-config-key.ts index ca4f8ac925..d053cda6e8 100644 --- a/apps/cli/src/config/cli-config-key.ts +++ b/apps/cli/src/config/cli-config-key.ts @@ -392,6 +392,7 @@ export const describeCliConfigEnvLocation = ( interface CliConfigValueFailure { readonly path: string; + readonly segments: ReadonlyArray; readonly raw: string; readonly expected: string; readonly origin: Exclude; @@ -404,13 +405,14 @@ const invalidCliConfigValue = ( failure: CliConfigValueFailure, ctx: Pick, ): CliConfigValueError => { - const { path, raw, expected, origin, typeOnly } = failure; + const { path, segments, raw, expected, origin, typeOnly } = failure; const source = describeCliConfigOrigin(origin, ctx); const kind = typeOnly ? "type" : expected.startsWith("one of") ? "enum" : "value"; switch (origin.tier) { case "flag": return new CliConfigValueError({ path, + segments, tier: "flag", source, flag: origin.flag, @@ -426,6 +428,7 @@ const invalidCliConfigValue = ( : ""; return new CliConfigValueError({ path, + segments, tier: origin.tier, source, envName: origin.envName, @@ -438,6 +441,7 @@ const invalidCliConfigValue = ( const where = describeCliConfigOrigin(origin, ctx); return new CliConfigValueError({ path, + segments, tier: "config", source, expected, @@ -473,6 +477,7 @@ export const pickCliConfigKey = failure.issues ?? []); return yield* new CliConfigValueError({ path: firstFailure.path, + ...(entryFailures.length === 1 && firstFailure.segments !== undefined + ? { segments: firstFailure.segments } + : {}), tier: "config", message: decodingFailedMessage(issues), issues, diff --git a/apps/cli/src/config/cli-config.errors.ts b/apps/cli/src/config/cli-config.errors.ts index ffd4dadb86..026feadec4 100644 --- a/apps/cli/src/config/cli-config.errors.ts +++ b/apps/cli/src/config/cli-config.errors.ts @@ -13,6 +13,8 @@ import type { CliConfigTier } from "./cli-config-key.ts"; */ export class CliConfigValueError extends Data.TaggedError("CliConfigValueError")<{ readonly path: string; + /** The key's path segments, for keys whose segments may contain `.`; `path` is these joined. */ + readonly segments?: ReadonlyArray; readonly tier: CliConfigTier; readonly message: string; readonly source?: string; diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index fd710eb2b4..64b763c568 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -92,13 +92,24 @@ function tomlHeaderSpan(source: string, token: TomlHeaderToken): Span { function inlineValueEnd(source: string, start: number, limit: number): number { let depth = 0; let quote: string | undefined; + let triple = false; for (let index = start; index < limit; index += 1) { const character = source[index]; if (quote !== undefined) { - if (character === "\\" && quote === '"') index += 1; - else if (character === quote) quote = undefined; + if (character === "\\" && quote === '"') { + index += 1; + } else if (character === quote && (!triple || source.startsWith(quote.repeat(3), index))) { + if (triple) { + index += 2; + for (let extra = 0; extra < 2 && source[index + 1] === quote; extra += 1) index += 1; + } + quote = undefined; + triple = false; + } } else if (character === '"' || character === "'") { quote = character; + triple = source.startsWith(character.repeat(3), index); + if (triple) index += 2; } else if (character === "{" || character === "[") { depth += 1; } else if (character === "}" || character === "]") { @@ -293,14 +304,22 @@ function childOf(node: Node, segment: string): { entry: Node; value: Node } | un return entry === undefined || value === undefined ? undefined : { entry, value }; } +interface ScannedJson { + readonly root: Node | undefined; + readonly starts: ReadonlyArray; +} + +const scanJson = (source: string): ScannedJson => ({ + root: parseTree(source, [], { allowTrailingComma: true, disallowComments: false }), + starts: lineStarts(source), +}); + function locateJson( - source: string, path: ReadonlyArray, target: "key" | "value", + { root, starts }: ScannedJson, ): CliConfigLocation | undefined { - const root = parseTree(source, [], { allowTrailingComma: true, disallowComments: false }); if (root === undefined) return undefined; - const starts = lineStarts(source); for (let length = path.length; length > 0; length -= 1) { let node: Node | undefined = root; @@ -342,7 +361,8 @@ export function scanCliConfigDocument(text: string, format: ConfigFormat): CliCo locate: (path, target) => locateToml(source, path, target, scanned), }; } + const scanned = scanJson(source); return { - locate: (path, target) => locateJson(source, path, target), + locate: (path, target) => locateJson(path, target, scanned), }; } diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index efcf40e374..5a944367d6 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -201,4 +201,12 @@ describe("locateCliConfigKey inline-table keys", () => { expect(location?.exact).toBe(true); expect(source.slice((location?.column ?? 0) - 1, (location?.endColumn ?? 0) - 1)).toBe('"bad"'); }); + + test("matches the real key after a triple-quoted string that holds key-like text", () => { + const source = 'api = { note = """x", port = "decoy", y = "z""", port = 0 }\n'; + const location = locateCliConfigKey(source, "toml", ["api", "port"], "value"); + + expect(location?.exact).toBe(true); + expect(source.slice((location?.column ?? 0) - 1, (location?.endColumn ?? 0) - 1)).toBe("0"); + }); }); From 44d27f902f9785dbad8e49efd02551f96d0146f5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 11 Nov 2026 11:36:28 +0000 Subject: [PATCH 09/12] fix(cli): validate duplicate remotes and handle dotted and bracketed names - Remote failures carry a structured kind; a duplicate project_id still validates its remote. - Removed-key diagnostics carry segmented paths and the remote, quoting TOML segments in hints. - TOML header spans end at the key path, so a quoted `]` no longer truncates them. - Unresolved env() warnings respect the enabled gate for every reference and skip remote project_ids supplied by their override variable. - JSON problems omit `file` when no file is associated. - Invalidating config values also clears the validation document cache. --- .../config/validate/validate.format.ts | 4 +- .../validate/validate.format.unit.test.ts | 8 + .../config/validate/validate.handler.ts | 165 ++++++++++-------- .../validate/validate.integration.test.ts | 63 +++++++ .../config/validate/validate.model.ts | 2 + .../cli-config-failure-key.unit.test.ts | 7 +- apps/cli/src/config/cli-config-remote.ts | 7 + .../cli-config-values.integration.test.ts | 26 +++ .../cli/src/config/cli-config-values.layer.ts | 1 + packages/config/src/config-locate.ts | 5 +- .../config/src/config-locate.unit.test.ts | 11 ++ packages/config/src/errors.ts | 8 +- packages/config/src/removed-config-keys.ts | 53 ++++-- packages/config/src/toml-scan.ts | 3 + 14 files changed, 271 insertions(+), 92 deletions(-) diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index 764ed511b8..a8fd4c0a79 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -93,7 +93,9 @@ function renderProblem( ): ReadonlyArray { const glyph = problem.severity === "error" ? style.red("✗") : style.yellow("⚠"); const subject = safe( - problem.path === undefined ? (problem.subject ?? report.file) : displayPath(problem.path), + problem.path === undefined + ? (problem.subject ?? problem.label ?? report.file) + : displayPath(problem.path), ); const remote = problem.remote === undefined ? "" : ` ${style.dim(`[remote: ${safe(problem.remote)}]`)}`; diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index f4bee48dbc..084a89359c 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -107,6 +107,14 @@ describe("validateSummaryMessage", () => { }); describe("validateJsonPayload", () => { + it("shows a label in the text report but emits no file for a file-less problem", () => { + const labelled = problem({ code: "env_file", label: "environment variables" }); + const result = report([labelled]); + + expect(validateReport(result, PLAIN_REPORT_STYLE)).toContain("✗ environment variables bad"); + expect(validateJsonPayload(result).errors[0]).not.toHaveProperty("file"); + }); + it("carries keys, codes and positions but no source text", () => { const payload = validateJsonPayload( report( diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index 36973c297a..1c4b29418e 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -39,6 +39,7 @@ import { describeCliConfigOrigin, type CliConfigKeyOrigin, } from "../../../config/cli-config-key.ts"; +import type { CliConfigRemoteFailure } from "../../../config/cli-config-remote.ts"; import { getDocumentValue, isDocumentRecord } from "../../../config/cli-config-document.ts"; import { flattenSchemaIssues } from "../../../config/cli-config-schema-issues.ts"; import { @@ -111,26 +112,27 @@ const withRemote = ( const reportFormat = (file: string, format: FileFormat): ReportFormat => format === "toml" ? "toml" : file.endsWith(".jsonc") ? "jsonc" : "json"; -const stripBrackets = (key: string): string => key.replace(/^\[|\]$/g, ""); +const TOML_BARE_KEY = /^[A-Za-z0-9_-]+$/; + +const tomlSegment = (segment: string): string => + TOML_BARE_KEY.test(segment) ? segment : JSON.stringify(segment); function removedKeyProblems( parsed: ParsedCliConfigDocument, format: ReportFormat, ): ReadonlyArray { - return parsed.removedKeys.map(({ key, replacement }) => { - const split = splitRemotePath(stripBrackets(key).split(".")); - const local = displayPath(split.path); - const replacementLocal = splitRemotePath(stripBrackets(replacement).split(".")).path.join("."); - const isTable = key.startsWith("["); - const scope = split.remote === undefined ? "" : `remotes.${split.remote}.`; + return parsed.removedKeys.map(({ key, path, replacementPath, remote }) => { + const scope = remote === undefined ? [] : ["remotes", remote]; + const tomlTable = (segments: ReadonlyArray): string => + `[${[...scope, ...segments].map(tomlSegment).join(".")}]`; return problem("removed_key", { - path: split.path, + path, reason: "removed", help: - isTable && format === "toml" - ? `rename [${scope}${local}] to [${scope}${replacementLocal}]` - : `use ${replacementLocal} instead`, - ...(split.remote === undefined ? {} : { remote: split.remote }), + key.startsWith("[") && format === "toml" + ? `rename ${tomlTable(path)} to ${tomlTable(replacementPath)}` + : `use ${displayPath(replacementPath)} instead`, + ...(remote === undefined ? {} : { remote }), }); }); } @@ -313,7 +315,7 @@ function envLoadProblem(error: CliConfigLoadError): ConfigProblem { const { file } = error; if (file === undefined) { return problem("env_file", { - subject: "environment variables", + label: "environment variables", reason: "could not be resolved", help: "check the variables set in your shell", }); @@ -341,25 +343,22 @@ function remotesShapeProblems(rawRemotes: unknown): ReadonlyArray } function remoteFailureProblem( - failure: { remote: string; message: string; envName?: string }, + failure: Pick, file: string, ): ConfigProblem { - const duplicate = /^duplicate project_id for \[remotes\.[^\]]+\] and \[remotes\.([^\]]+)\]$/.exec( - failure.message, - ); return problem("remote_project_id", { path: ["project_id"], remote: failure.remote, reason: - duplicate?.[1] === undefined - ? "not a project ref" - : `same project_id as remotes.${duplicate[1]}`, + failure.kind === "duplicate" + ? `same project_id as remotes.${failure.duplicateOf}` + : "not a project ref", ...(failure.envName === undefined ? { help: - duplicate?.[1] === undefined - ? "use the 20-character ref from your project's dashboard URL" - : "give each remote its own project_id", + failure.kind === "duplicate" + ? "give each remote its own project_id" + : "use the 20-character ref from your project's dashboard URL", } : { external: true as const, @@ -620,51 +619,62 @@ export const configValidate = Effect.fn("config.validate")(function* () { } const references = envReferences(parsed.rawDocument); - const { unresolved, unresolvedProjectIdRemotes } = yield* Effect.gen(function* () { - const none = { - unresolved: [] as ReadonlyArray, - unresolvedProjectIdRemotes: new Set(), - }; - if (references.length === 0) return none; - const resolved: ResolvedCliConfig | undefined = - base.resolved ?? - (yield* configValues - .load({ - workdir, - projectRef: Option.none(), - ignoreConfigFile: true, - validation: true, - }) - .pipe(Effect.option, Effect.map(Option.getOrUndefined))); - if (resolved === undefined) return none; - const projectIdRemotes = references.flatMap((reference) => - reference.remote !== undefined && reference.path.join(".") === "project_id" - ? [reference.remote] - : [], - ); - const values = yield* resolved - .envValues([ - ...references.map((reference) => reference.name), - ...projectIdRemotes.map(cliRemoteProjectIdEnvName), - ]) - .pipe(Effect.orElseSucceed((): Record => ({}))); - const unresolvedRefs = references.filter((reference) => values[reference.name] === undefined); - return { - unresolved: unresolvedRefs, - unresolvedProjectIdRemotes: new Set( - unresolvedRefs.flatMap((reference) => - reference.remote !== undefined && - reference.path.join(".") === "project_id" && - values[cliRemoteProjectIdEnvName(reference.remote)] === undefined - ? [reference.remote] - : [], + const { unresolved, unresolvedProjectIdRemotes, overriddenProjectIdRemotes } = + yield* Effect.gen(function* () { + const none = { + unresolved: [] as ReadonlyArray, + unresolvedProjectIdRemotes: new Set(), + overriddenProjectIdRemotes: new Set(), + }; + if (references.length === 0) return none; + const resolved: ResolvedCliConfig | undefined = + base.resolved ?? + (yield* configValues + .load({ + workdir, + projectRef: Option.none(), + ignoreConfigFile: true, + validation: true, + }) + .pipe(Effect.option, Effect.map(Option.getOrUndefined))); + if (resolved === undefined) return none; + const projectIdRemotes = references.flatMap((reference) => + reference.remote !== undefined && reference.path.join(".") === "project_id" + ? [reference.remote] + : [], + ); + const values = yield* resolved + .envValues([ + ...references.map((reference) => reference.name), + ...projectIdRemotes.map(cliRemoteProjectIdEnvName), + ]) + .pipe(Effect.orElseSucceed((): Record => ({}))); + const unresolvedRefs = references.filter( + (reference) => values[reference.name] === undefined, + ); + return { + unresolved: unresolvedRefs, + overriddenProjectIdRemotes: new Set( + projectIdRemotes.filter( + (name) => values[cliRemoteProjectIdEnvName(name)] !== undefined, + ), ), - ), - }; - }).pipe(Effect.withSpan("config.validate.env")); + unresolvedProjectIdRemotes: new Set( + unresolvedRefs.flatMap((reference) => + reference.remote !== undefined && + reference.path.join(".") === "project_id" && + values[cliRemoteProjectIdEnvName(reference.remote)] === undefined + ? [reference.remote] + : [], + ), + ), + }; + }).pipe(Effect.withSpan("config.validate.env")); const failedRemotes = new Map( - (base.resolved?.remoteFailures ?? []).map((failure) => [failure.remote, failure]), + (base.resolved?.remoteFailures ?? []) + .filter((failure) => failure.kind === "invalid") + .map((failure) => [failure.remote, failure]), ); const skippedRemotes = remoteNames .filter((name) => failedRemotes.has(name)) @@ -719,15 +729,16 @@ export const configValidate = Effect.fn("config.validate")(function* () { unresolvedFor, ); - const isRelevantIn = (reference: EnvReference, context: string | undefined): boolean => { - if (CLI_CONFIG_OPTIONAL_ENV_PATHS[displayPath(reference.path)] !== undefined) return false; + const isGateOpen = (reference: EnvReference, context: string | undefined): boolean => { const owner = passes.get(context)?.resolved; - const gate = nearestEnabledGate( - [owner?.materialized.config, owner?.loaded.document], - reference.path, + return ( + nearestEnabledGate([owner?.materialized.config, owner?.loaded.document], reference.path) !== + false ); - return gate !== false; }; + const isRelevantIn = (reference: EnvReference, context: string | undefined): boolean => + CLI_CONFIG_OPTIONAL_ENV_PATHS[displayPath(reference.path)] === undefined && + isGateOpen(reference, context); const envWarning = (reference: EnvReference, remote: string | undefined) => problem("env_unresolved", { severity: "warning", @@ -745,8 +756,16 @@ export const configValidate = Effect.fn("config.validate")(function* () { const envWarnings = unresolved.flatMap((reference) => { const warns = (context: string | undefined) => !isOverridden(reference, context) && - (blamed.some((item) => item.reference === reference && item.remote === context) || - isRelevantIn(reference, context)); + (blamed.some((item) => item.reference === reference && item.remote === context) + ? isGateOpen(reference, context) + : isRelevantIn(reference, context)); + if ( + reference.remote !== undefined && + reference.path.join(".") === "project_id" && + overriddenProjectIdRemotes.has(reference.remote) + ) { + return []; + } if (reference.remote !== undefined) { return warns(reference.remote) ? [envWarning(reference, reference.remote)] : []; } diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index be826bc741..1c904e9b9e 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -422,6 +422,50 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("still validates a duplicate remote and names a bracketed original", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + `project_id = "demo"\n[remotes."a]b"]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.b]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.b.api]\nmax_rows = "lots"\n`, + ), + { format: "json" }, + ); + + const data = payloadOf(result) ?? {}; + expect(Reflect.get(data, "remotes")).toContain("b"); + expect(Reflect.get(data, "skipped_remotes")).toBeUndefined(); + expect(Reflect.get(data, "errors")).toMatchObject([ + { code: "remote_project_id", remote: "b", reason: "same project_id as remotes.a]b" }, + { code: "invalid_value", remote: "b", key: "remotes.b.api.max_rows" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("does not warn about an unset env() under a disabled section it also fails", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[api]\nenabled = false\nmax_rows = "env(UNSET_ROWS)"\n'), + { format: "json" }, + ); + + expect(payloadOf(result)).toMatchObject({ valid: true, warnings: [] }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("does not warn about a remote project_id env() its own override replaces", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[remotes.prod]\nproject_id = "env(UNSET_REF)"\n'), + { format: "json", env: { SUPABASE_REMOTES_PROD_PROJECT_ID: "nope" } }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "warnings")).toEqual([]); + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { code: "remote_project_id", remote: "prod" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("stops and says so when the env files cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -785,6 +829,25 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("keeps a dotted remote name whole in a removed-table rename hint", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[remotes."prod.eu"]\nproject_id = "abcdefghijklmnopqrst"\n[remotes."prod.eu".inbucket]\nport = 54324\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { + code: "removed_key", + remote: "prod.eu", + help: 'rename [remotes."prod.eu".inbucket] to [remotes."prod.eu".local_smtp]', + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("reports a codec enum failure from the environment as invalid_enum, unquoted", () => Effect.gen(function* () { const result = yield* validate(tomlFile('project_id = "demo"\n'), { diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts index cf4e37df40..8c780af90a 100644 --- a/apps/cli/src/commands/config/validate/validate.model.ts +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -26,6 +26,8 @@ export interface ConfigProblem { readonly remote?: string; /** Names the file a pathless problem is about when it is not the validated one. */ readonly subject?: string; + /** Names a pathless problem's subject in the text report when no file is associated with it. */ + readonly label?: string; /** What is wrong, short and free of configured values. */ readonly reason: string; readonly help?: string; diff --git a/apps/cli/src/config/cli-config-failure-key.unit.test.ts b/apps/cli/src/config/cli-config-failure-key.unit.test.ts index 0c916099e1..a153537f23 100644 --- a/apps/cli/src/config/cli-config-failure-key.unit.test.ts +++ b/apps/cli/src/config/cli-config-failure-key.unit.test.ts @@ -95,7 +95,12 @@ describe("cliConfigFailureKey for a removed key", () => { new CliConfigRemovedKeyError({ path: "supabase/config.toml", format: "toml", - removedKeys: keys.map((key) => ({ key, replacement: "[replacement]" })), + removedKeys: keys.map((key) => ({ + key, + replacement: "[replacement]", + path: [key], + replacementPath: ["replacement"], + })), }); expect(cliConfigFailureKey(removedKeys(`[${SECRET}]`, "[inbucket]"))).toBe("inbucket"); diff --git a/apps/cli/src/config/cli-config-remote.ts b/apps/cli/src/config/cli-config-remote.ts index cb7851676b..1970694f1f 100644 --- a/apps/cli/src/config/cli-config-remote.ts +++ b/apps/cli/src/config/cli-config-remote.ts @@ -40,6 +40,10 @@ export const selectCliConfigRemote = ( export interface CliConfigRemoteFailure { readonly remote: string; + /** `duplicate` repeats another remote's `project_id`; `invalid` is not a project ref. */ + readonly kind: "duplicate" | "invalid"; + /** The remote that first used the same `project_id`, for a `duplicate`. */ + readonly duplicateOf?: string; readonly message: string; /** The env variable that supplied the rejected `project_id`, when it overrode the file. */ readonly envName?: string; @@ -64,6 +68,8 @@ export const cliConfigRemoteFailures = ( if (prior !== undefined) { failures.set(name, { remote: name, + kind: "duplicate", + duplicateOf: prior, message: `duplicate project_id for [remotes.${name}] and [remotes.${prior}]`, ...overrideField(name, lookup), }); @@ -80,6 +86,7 @@ export const cliConfigRemoteFailures = ( if ((projectId === undefined || !PROJECT_REF_PATTERN.test(projectId)) && !failures.has(name)) { failures.set(name, { remote: name, + kind: "invalid", message: `Invalid config for remotes.${name}.project_id. Must be like: abcdefghijklmnopqrst`, ...overrideField(name, lookup), }); diff --git a/apps/cli/src/config/cli-config-values.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts index 9ba5636b7b..b1d5cf1da5 100644 --- a/apps/cli/src/config/cli-config-values.integration.test.ts +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -2586,6 +2586,32 @@ port = 6000 }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), ); + it.live("re-reads the config file for a validation load after invalidate", () => + Effect.gen(function* () { + const root = yield* project("[db]\nport = 5000\n"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const layer = configValuesLayer(); + const target = { workdir: root, projectRef: Option.none(), validation: true as const }; + + const port = (values: CliConfigValues["Service"]) => + values.load(target).pipe(Effect.map((loaded) => loaded.materialized.config.db.port)); + const [before, after] = yield* CliConfigValues.use((values) => + Effect.gen(function* () { + const first = yield* port(values); + yield* fs.writeFileString( + path.join(root, "supabase", "config.toml"), + "[db]\nport = 7000\n", + ); + yield* values.invalidate; + return [first, yield* port(values)] as const; + }), + ).pipe(Effect.provide(layer)); + + expect([before, after]).toEqual([5000, 7000]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + it.live("prunes past every schema failure only when validating", () => Effect.gen(function* () { const root = yield* project("api = 1\nstudio = 2\n"); diff --git a/apps/cli/src/config/cli-config-values.layer.ts b/apps/cli/src/config/cli-config-values.layer.ts index b0b3321aa5..aade192139 100644 --- a/apps/cli/src/config/cli-config-values.layer.ts +++ b/apps/cli/src/config/cli-config-values.layer.ts @@ -732,6 +732,7 @@ export const cliConfigValuesLayer = Layer.effect( const invalidate = Effect.all([ Cache.invalidateAll(cache), Cache.invalidateAll(parsedDocuments), + Cache.invalidateAll(validationParsedDocuments), ]).pipe(Effect.asVoid); return CliConfigValues.of({ diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index 57418b437d..d3f819d358 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -83,10 +83,7 @@ function tomlKeySpan(source: string, token: TomlKeyToken): Span { function tomlHeaderSpan(source: string, token: TomlHeaderToken): Span { const open = skipInlineWhitespace(source, token.start); const start = skipInlineWhitespace(source, open + (token.isArrayOfTables ? 2 : 1)); - const lineEnd = source.indexOf("\n", start); - const line = source.slice(start, lineEnd === -1 ? source.length : lineEnd); - const close = line.indexOf("]"); - return { start, end: trimEnd(source, close === -1 ? start + line.length : start + close) }; + return { start, end: trimEnd(source, token.keysEnd) }; } function inlineValueEnd(source: string, start: number, limit: number): number { diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index 5a944367d6..cbda9b0a1a 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -71,6 +71,17 @@ describe("locateCliConfigKey (toml)", () => { expect(location?.endColumn).toBe(2 + "auth.external.github".length); }); + test("spans a header whose quoted segment contains a closing bracket", () => { + const header = '[storage.buckets."photos]archive"]'; + const location = locateCliConfigKey( + `${header}\npublic = true\n`, + "toml", + ["storage", "buckets", "photos]archive"], + "key", + ); + expect(location).toMatchObject({ line: 1, column: 2, endColumn: header.length, exact: true }); + }); + test("falls back to the parent header for a missing leaf", () => { expect( locateCliConfigKey(toml, "toml", ["auth", "external", "github", "secret"], "key"), diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index 1c02cbbe9f..3a53e4a718 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -22,7 +22,13 @@ export class CliConfigParseError extends Data.TaggedError("CliConfigParseError") export class CliConfigRemovedKeyError extends Data.TaggedError("CliConfigRemovedKeyError")<{ readonly path: string; readonly format: ConfigFormat; - readonly removedKeys: ReadonlyArray<{ readonly key: string; readonly replacement: string }>; + readonly removedKeys: ReadonlyArray<{ + readonly key: string; + readonly replacement: string; + readonly path: ReadonlyArray; + readonly replacementPath: ReadonlyArray; + readonly remote?: string; + }>; }> { override get message(): string { const file = this.path.split(/[\\/]/).slice(-2).join("/"); diff --git a/packages/config/src/removed-config-keys.ts b/packages/config/src/removed-config-keys.ts index 4d42f9db8e..3b60b49c00 100644 --- a/packages/config/src/removed-config-keys.ts +++ b/packages/config/src/removed-config-keys.ts @@ -5,11 +5,37 @@ import { CliConfigRemovedKeyError } from "./errors.ts"; type RemovedConfigKey = CliConfigRemovedKeyError["removedKeys"][number]; +const BARE_KEY = /^[A-Za-z0-9_-]+$/; + +const tomlSegment = (segment: string): string => + BARE_KEY.test(segment) ? segment : JSON.stringify(segment); + +const removed = ( + remote: string | undefined, + local: ReadonlyArray, + replacementLocal: ReadonlyArray, + table: boolean, +): RemovedConfigKey => { + const render = (segments: ReadonlyArray): string => { + const dotted = [...(remote === undefined ? [] : ["remotes", remote]), ...segments] + .map(tomlSegment) + .join("."); + return table ? `[${dotted}]` : dotted; + }; + return { + key: render(local), + replacement: render(replacementLocal), + path: local, + replacementPath: replacementLocal, + ...(remote === undefined ? {} : { remote }), + }; +}; + const REMOVED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; function scanScope( scope: Record, - prefix: string, + remote: string | undefined, dropRemoved: boolean, ): { readonly removedKeys: ReadonlyArray; @@ -19,7 +45,7 @@ function scanScope( let stripped = scope; if (Object.hasOwn(scope, "inbucket")) { - removedKeys.push({ key: `[${prefix}inbucket]`, replacement: `[${prefix}local_smtp]` }); + removedKeys.push(removed(remote, ["inbucket"], ["local_smtp"], true)); if (dropRemoved) { stripped = { ...stripped }; delete stripped["inbucket"]; @@ -33,10 +59,9 @@ function scanScope( delete withoutEmptyVersion["orioledb_version"]; stripped = { ...stripped, experimental: withoutEmptyVersion }; } else { - removedKeys.push({ - key: `${prefix}experimental.orioledb_version`, - replacement: `${prefix}db.orioledb_version`, - }); + removedKeys.push( + removed(remote, ["experimental", "orioledb_version"], ["db", "orioledb_version"], false), + ); if (dropRemoved) { const withoutVersion = { ...experimental }; delete withoutVersion["orioledb_version"]; @@ -50,10 +75,14 @@ function scanScope( if (isObject(external)) { for (const provider of REMOVED_EXTERNAL_PROVIDERS) { if (Object.hasOwn(external, provider)) { - removedKeys.push({ - key: `[${prefix}auth.external.${provider}]`, - replacement: `[${prefix}auth.external.${provider}_oidc]`, - }); + removedKeys.push( + removed( + remote, + ["auth", "external", provider], + ["auth", "external", `${provider}_oidc`], + true, + ), + ); if (dropRemoved) { const strippedAuth = stripped["auth"]; const strippedExternal = isObject(strippedAuth) ? strippedAuth["external"] : undefined; @@ -85,7 +114,7 @@ function detectRemovedConfigKeys( readonly removedKeys: ReadonlyArray; readonly document: Record; } { - const root = scanScope(document, "", dropRemoved); + const root = scanScope(document, undefined, dropRemoved); const removedKeys = [...root.removedKeys]; const remotes = document["remotes"]; if (!isObject(remotes)) { @@ -96,7 +125,7 @@ function detectRemovedConfigKeys( if (!isObject(remote)) { return [name, remote]; } - const scanned = scanScope(remote, `remotes.${name}.`, dropRemoved); + const scanned = scanScope(remote, name, dropRemoved); removedKeys.push(...scanned.removedKeys); return [name, scanned.scope]; }), diff --git a/packages/config/src/toml-scan.ts b/packages/config/src/toml-scan.ts index da66e2ed02..9576ae488f 100644 --- a/packages/config/src/toml-scan.ts +++ b/packages/config/src/toml-scan.ts @@ -8,6 +8,8 @@ export interface TomlHeaderToken { readonly end: number; readonly path: ReadonlyArray; readonly isArrayOfTables: boolean; + /** Offset just past the dotted key path, before any whitespace and the closing bracket. */ + readonly keysEnd: number; /** Offset of a same-line `#` after the closing bracket. */ readonly commentStart?: number; } @@ -432,6 +434,7 @@ export function scanTomlDocument(source: string): TomlScanResult | TomlScanError end, path: parsedPath.segments, isArrayOfTables, + keysEnd: parsedPath.end, ...(headerComment === undefined ? {} : { commentStart: headerComment }), }; headers.push(headerToken); From c56ab3d7e7b0f0f5d4fcb161c3e0ae552c35f516 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 12 Nov 2026 09:31:55 +0000 Subject: [PATCH 10/12] fix(cli): validate every remote block and keep unresolved env() from failing a run - Every [remotes.*] block is validated even when its project_id is unusable; the skipped_remotes field is gone. - A cross-key error that depends on an unresolved env() reference is suppressed. - Keys inside [auth.passkey] and [auth.webauthn] are checked against the schema. - Provider `enabled` problems are reported under the key the document uses. - Duplicate JSON keys locate the occurrence the parser keeps. - A run with problems is recorded as an invalid_config failure (ConfigValidateProblemsError). - env() warnings check overrides on ancestor keys and read gates from the document when a table was pruned. - The empty enum choice renders as "", and the content_path help names the right key. --- .../command-internal/local-config-values.ts | 2 +- .../commands/config/validate/SIDE_EFFECTS.md | 6 +- .../config/validate/validate.errors.ts | 9 ++ .../config/validate/validate.format.ts | 8 - .../validate/validate.format.unit.test.ts | 5 +- .../config/validate/validate.handler.ts | 69 ++++----- .../validate/validate.integration.test.ts | 139 ++++++++++++++++-- .../config/validate/validate.model.ts | 26 +++- .../src/config/cli-config-schema-issues.ts | 2 +- .../cli-config-schema-issues.unit.test.ts | 9 ++ .../cli/src/config/cli-config-unknown-keys.ts | 53 +++++-- .../cli-config-unknown-keys.unit.test.ts | 17 +++ .../telemetry/__fixtures__/error-tags.txt | 1 + apps/cli/src/telemetry/command-telemetry.ts | 6 +- .../telemetry/command-telemetry.unit.test.ts | 31 ++++ packages/config/src/config-locate.ts | 2 +- .../config/src/config-locate.unit.test.ts | 6 + 17 files changed, 304 insertions(+), 87 deletions(-) diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index 9c77f8b91f..e5bbb2f18a 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -195,7 +195,7 @@ const SIGNED_KEY_ISSUE: IssueMeta = { const CONTENT_PATH_ISSUE: IssueMeta = { reason: "content_path is not a readable file inside the project", - help: "point content_path at an existing file under the project root, or use content_path instead of content", + help: "point content_path at an existing file under the project root, or use `content` instead of `content_path`", }; /** Without a collector `effect` runs unchanged; with one a failure is recorded at `path` and `fallback` stands in. */ diff --git a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md index ea806b5370..a30b70ff76 100644 --- a/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/validate/SIDE_EFFECTS.md @@ -45,7 +45,7 @@ | `1` | a `[remotes.*]` block has an invalid or duplicate `project_id` | | `1` | no project config found, `--workdir` is not a directory, or `-o/--output` was passed (generic error envelope in JSON mode) | -Telemetry records a run that reports problems as a successful command, because the exit code is set after the handler succeeds. +Telemetry records a run that exits 1 as a failed command classified as a user-actionable `invalid_config` error (`ConfigValidateProblemsError`), because the exit code is set after the handler succeeds rather than by a raised error. ## Run order and stops @@ -59,7 +59,7 @@ stdout is one report: every problem in file order, each as ` ` wit ### `--output-format json` -`{"schema_version":1,"valid","complete","stopped_at"?,"file","format","remotes":[],"skipped_remotes"?,"errors":[],"warnings":[],"message"}`. Each entry has `code`, `severity`, `reason` and, when known, `key`, `path`, `remote`, `file`, `help`, `line`, `column`, `end_line`, `end_column`. Codes: `syntax`, `file_conflict`, `schema_version`, `removed_key`, `unknown_key`, `invalid_type`, `invalid_enum`, `invalid_value`, `missing_required`, `remote_project_id`, `env_file`, `removed_env`, `env_unresolved`, `deprecated_key`, `shadowed_file`, `unrecognized_schema`. It never contains source text or configured values. Failures before a report exists (no config, bad `--workdir`, `-o`) use the generic `{"_tag":"Error",...}` envelope. +`{"schema_version":1,"valid","complete","stopped_at"?,"file","format","remotes":[],"errors":[],"warnings":[],"message"}`. Each entry has `code`, `severity`, `reason` and, when known, `key`, `path`, `remote`, `file`, `help`, `line`, `column`, `end_line`, `end_column`. Codes: `syntax`, `file_conflict`, `schema_version`, `removed_key`, `unknown_key`, `invalid_type`, `invalid_enum`, `invalid_value`, `missing_required`, `remote_project_id`, `env_file`, `removed_env`, `env_unresolved`, `deprecated_key`, `shadowed_file`, `unrecognized_schema`. It never contains source text or configured values. Failures before a report exists (no config, bad `--workdir`, `-o`) use the generic `{"_tag":"Error",...}` envelope. ### `--output-format stream-json` @@ -69,5 +69,5 @@ Same structured result as `json`, delivered as an NDJSON `result` event. - No config or project file is created or changed and no Management API call is made. The CLI still writes `telemetry.json`, and the global update notice that follows every command can fetch the latest release tag and write `cli-latest` and print a notice on stderr; this command does not change that behaviour. - Every `[remotes.]` block is validated merged over the base config, whatever the linked project is. -- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and value errors (type, enum, value, missing) it causes at or below that key are not reported; unknown-key, removed-key and `SUPABASE_*`-sourced errors still are. A remote whose `project_id` is an unset `env()` reference is skipped with a warning, and a reference a `SUPABASE_*` variable overrides is not reported. A base reference a remote inherits is checked against that remote's own gates. A `remotes` key or `remotes.` entry that is not a table is an `invalid_type` error. An error whose value a `SUPABASE_*` variable supplies names the variable and carries no file location. +- An unresolved `env(NAME)` is a warning when its nearest `enabled` gate is not `false` (or the key is an optional placeholder `supabase init` writes), and value errors (type, enum, value, missing) it causes at or below that key, and cross-key checks that read it (an `enabled` gate, an MFA factor's `verify_enabled`), are not reported; unknown-key, removed-key and `SUPABASE_*`-sourced errors still are. A remote whose `project_id` is an unset `env()` reference or invalid is still validated, with the `project_id` problem reported alongside; a reference a `SUPABASE_*` variable overrides is not reported. A base reference a remote inherits is checked against that remote's own gates. A `remotes` key or `remotes.` entry that is not a table is an `invalid_type` error. An error whose value a `SUPABASE_*` variable supplies names the variable and carries no file location. - `-o/--output` is rejected in favour of `--output-format`. diff --git a/apps/cli/src/commands/config/validate/validate.errors.ts b/apps/cli/src/commands/config/validate/validate.errors.ts index ce9422462a..7463932f91 100644 --- a/apps/cli/src/commands/config/validate/validate.errors.ts +++ b/apps/cli/src/commands/config/validate/validate.errors.ts @@ -31,3 +31,12 @@ export class ConfigValidateOutputFlagUnsupportedError extends Data.TaggedError( return actionability.provideFlags; } } + +/** The config has problems; classifies the exit-code-1 run that reports them without failing the command. */ +export class ConfigValidateProblemsError extends Data.TaggedError("ConfigValidateProblemsError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/commands/config/validate/validate.format.ts b/apps/cli/src/commands/config/validate/validate.format.ts index a8fd4c0a79..a7eee12781 100644 --- a/apps/cli/src/commands/config/validate/validate.format.ts +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -46,7 +46,6 @@ export interface ValidateReport { readonly file: string; readonly format: "toml" | "json" | "jsonc"; readonly remotes: ReadonlyArray; - readonly skippedRemotes: ReadonlyArray<{ readonly name: string; readonly reason: string }>; readonly problems: ReadonlyArray; readonly stoppedAt?: StopStage; } @@ -141,12 +140,6 @@ export function validateReport( if (report.remotes.length > 0) { out.push(` ${style.dim(`Checked remotes: ${report.remotes.map(safe).join(", ")}`)}`); } - if (report.skippedRemotes.length > 0) { - const skipped = report.skippedRemotes.map( - ({ name, reason }) => `${safe(name)} (${safe(reason)})`, - ); - out.push(` ${style.dim(`Skipped remotes: ${skipped.join(", ")}`)}`); - } if (report.stoppedAt !== undefined) { out.push( "", @@ -191,7 +184,6 @@ export function validateJsonPayload(report: ValidateReport) { file: report.file, format: report.format, remotes: report.remotes, - ...(report.skippedRemotes.length === 0 ? {} : { skipped_remotes: report.skippedRemotes }), errors: errors.map(payloadProblem), warnings: warningsOf(report).map(payloadProblem), }; diff --git a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts index 084a89359c..67047d8ec3 100644 --- a/apps/cli/src/commands/config/validate/validate.format.unit.test.ts +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -23,7 +23,6 @@ const report = (problems: ReadonlyArray, extra: Partial { expect(text).toContain(" ✗ api.port bad\n help: fix it\n"); }); - it("labels remote problems and lists checked and skipped remotes", () => { + it("labels remote problems and lists checked remotes", () => { const text = render([problem({ path: ["db", "port"], remote: "staging" })], { remotes: ["staging"], - skippedRemotes: [{ name: "prod", reason: "no project id" }], }); expect(text).toContain("[remote: staging]"); expect(text).toContain("Checked remotes: staging"); - expect(text).toContain("Skipped remotes: prod (no project id)"); }); it("reports a valid file with a warning count", () => { diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index 1c4b29418e..fe4e656274 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -202,6 +202,24 @@ function locateProblem( ); } +/** Problems carry the canonical `enable_signup`; name the key by the spelling the document uses. */ +function inDocumentSpelling(item: ConfigProblem, document: CliConfigDocumentScan): ConfigProblem { + const { path } = item; + if (item.code === "deprecated_key" || path?.length !== 3 || path[0] !== "auth") return item; + if ((path[1] !== "email" && path[1] !== "sms") || path[2] !== "enable_signup") return item; + const preferred = ["auth", path[1], "enabled"]; + const written = document.locate( + item.remote === undefined ? preferred : ["remotes", item.remote, ...preferred], + "key", + ); + return written?.exact === true ? { ...item, path: preferred } : item; +} + +function locatedProblem(item: ConfigProblem, document: CliConfigDocumentScan): ConfigProblem { + const spelled = inDocumentSpelling(item, document); + return { ...spelled, location: locateProblem(spelled, document) }; +} + function deprecatedKeyProblems(parsed: ParsedCliConfigDocument): ReadonlyArray { return parsed.deprecatedPaths.map((dotted) => { const split = splitRemotePath(dotted.split(".")); @@ -260,7 +278,9 @@ const schemaProblems = ( function invalidValueProblem(error: CliConfigValueError, file: string): ConfigProblem { const fromEnv = error.tier === "shell" || error.tier === "projectEnv" || error.tier === "flag"; - const unquotedExpected = error.expected?.replaceAll('"', ""); + const unquotedExpected = error.expected?.replace(/"([^",]*)"/g, (quoted, choice: string) => + choice === "" ? quoted : choice, + ); const reason = unquotedExpected !== undefined ? `expected ${unquotedExpected}` @@ -298,6 +318,7 @@ function semanticProblem( const path = issue.segments ?? issue.path.split("."); const fromEnv = origin.tier === "shell" || origin.tier === "projectEnv"; return problem(code, { + derived: true as const, ...(issue.path === "" ? {} : { path }), reason: issue.reason, ...(fromEnv @@ -411,7 +432,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { file: display, format: reportFormat(display, format), remotes: [], - skippedRemotes: [], stoppedAt, problems: problems.map((item) => ({ ...item, @@ -608,12 +628,8 @@ export const configValidate = Effect.fn("config.validate")(function* () { file, format, remotes: [], - skippedRemotes: [], stoppedAt: base.stop, - problems: [...notices, ...base.problems].map((item) => ({ - ...item, - location: locateProblem(item, document), - })), + problems: [...notices, ...base.problems].map((item) => locatedProblem(item, document)), } satisfies ValidateReport; return yield* emit(report); } @@ -671,20 +687,7 @@ export const configValidate = Effect.fn("config.validate")(function* () { }; }).pipe(Effect.withSpan("config.validate.env")); - const failedRemotes = new Map( - (base.resolved?.remoteFailures ?? []) - .filter((failure) => failure.kind === "invalid") - .map((failure) => [failure.remote, failure]), - ); - const skippedRemotes = remoteNames - .filter((name) => failedRemotes.has(name)) - .map((name) => ({ - name, - reason: unresolvedProjectIdRemotes.has(name) - ? "project_id is an unset env() reference" - : "invalid project_id", - })); - const checkedRemotes = remoteNames.filter((name) => !failedRemotes.has(name)); + const checkedRemotes = remoteNames; const remotePasses = yield* Effect.forEach(checkedRemotes, (name) => pass(name), { concurrency: 1, }).pipe(Effect.withSpan("config.validate.remotes")); @@ -731,10 +734,11 @@ export const configValidate = Effect.fn("config.validate")(function* () { const isGateOpen = (reference: EnvReference, context: string | undefined): boolean => { const owner = passes.get(context)?.resolved; - return ( - nearestEnabledGate([owner?.materialized.config, owner?.loaded.document], reference.path) !== - false - ); + const fellBack = owner?.pruned.some((path) => path.every((s, i) => s === reference.path[i])); + const configs = fellBack + ? [owner?.loaded.document] + : [owner?.materialized.config, owner?.loaded.document]; + return nearestEnabledGate(configs, reference.path) !== false; }; const isRelevantIn = (reference: EnvReference, context: string | undefined): boolean => CLI_CONFIG_OPTIONAL_ENV_PATHS[displayPath(reference.path)] === undefined && @@ -748,10 +752,11 @@ export const configValidate = Effect.fn("config.validate")(function* () { ...(remote === undefined ? {} : { remote }), }); const isOverridden = (reference: EnvReference, context: string | undefined): boolean => { - const origin = passes - .get(context) - ?.resolved?.materialized.originAt(displayPath(reference.path)); - return origin?.tier === "shell" || origin?.tier === "projectEnv" || origin?.tier === "flag"; + const materialized = passes.get(context)?.resolved?.materialized; + return reference.path.some((_, index) => { + const origin = materialized?.originAt(displayPath(reference.path.slice(0, index + 1))); + return origin?.tier === "shell" || origin?.tier === "projectEnv" || origin?.tier === "flag"; + }); }; const envWarnings = unresolved.flatMap((reference) => { const warns = (context: string | undefined) => @@ -775,17 +780,13 @@ export const configValidate = Effect.fn("config.validate")(function* () { .map((name) => envWarning(reference, name)); }); const document = scanCliConfigDocument(parsed.rawText, parsed.format); - const located = [...kept, ...envWarnings].map((item) => ({ - ...item, - location: locateProblem(item, document), - })); + const located = [...kept, ...envWarnings].map((item) => locatedProblem(item, document)); const remoteStop = remotePasses.find((item) => item.stop !== undefined)?.stop; return yield* emit({ file, format, remotes: checkedRemotes, - skippedRemotes, ...(remoteStop === undefined ? {} : { stoppedAt: remoteStop }), problems: located, }); diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 1c904e9b9e..79af6a7e86 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -385,7 +385,7 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("reports every bad remote and still validates the healthy ones", () => + it.effect("reports every bad remote project_id and still validates every remote block", () => Effect.gen(function* () { const result = yield* validate( tomlFile( @@ -396,11 +396,8 @@ describe("config validate integration", () => { const data = payloadOf(result); expect(data).toMatchObject({ - remotes: ["c"], - skipped_remotes: [ - { name: "a", reason: "invalid project_id" }, - { name: "b", reason: "invalid project_id" }, - ], + remotes: ["a", "b", "c"], + complete: true, }); expect(Reflect.get(data ?? {}, "errors")).toMatchObject([ { code: "remote_project_id", key: "remotes.a.project_id", remote: "a" }, @@ -433,7 +430,6 @@ describe("config validate integration", () => { const data = payloadOf(result) ?? {}; expect(Reflect.get(data, "remotes")).toContain("b"); - expect(Reflect.get(data, "skipped_remotes")).toBeUndefined(); expect(Reflect.get(data, "errors")).toMatchObject([ { code: "remote_project_id", remote: "b", reason: "same project_id as remotes.a]b" }, { code: "invalid_value", remote: "b", key: "remotes.b.api.max_rows" }, @@ -754,19 +750,99 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("skips a remote whose project_id is an unset env() reference and warns instead", () => + it.effect( + "warns instead of failing for a remote whose project_id is an unset env() reference", + () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[remotes.prod]\nproject_id = "env(UNSET_REF)"\n'), + ); + + expect(result.exitCode).toBeUndefined(); + expect(result.stdout).toContain("⚠ project_id UNSET_REF is not set"); + expect(result.stdout).toContain("[remote: prod]"); + expect(result.stdout).toContain("Checked remotes: prod"); + expect(result.stdout).not.toContain("✗"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("validates a remote block whose project_id is unusable", () => Effect.gen(function* () { const result = yield* validate( - tomlFile('project_id = "demo"\n[remotes.prod]\nproject_id = "env(UNSET_REF)"\n'), + tomlFile( + 'project_id = "demo"\n[remotes.c]\nproject_id = "env(CPID)"\n[remotes.c.api]\nport = "zz"\n[remotes.c.db]\nmajor_version = 99\n', + ), + { format: "json" }, ); - expect(result.exitCode).toBeUndefined(); - expect(result.stdout).toContain("⚠ project_id UNSET_REF is not set"); - expect(result.stdout).toContain("[remote: prod]"); - expect(result.stdout).toContain( - "Skipped remotes: prod (project_id is an unset env() reference)", + const data = payloadOf(result) ?? {}; + expect(Reflect.get(data, "valid")).toBe(false); + expect(Reflect.get(data, "remotes")).toEqual(["c"]); + expect(Reflect.get(data, "errors")).toMatchObject([ + { remote: "c", key: "remotes.c.api.port" }, + { remote: "c", key: "remotes.c.db.major_version" }, + ]); + expect(Reflect.get(data, "warnings")).toMatchObject([ + { code: "env_unresolved", remote: "c" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("drops a cross-key check that reads an unresolved reference and warns about it", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.mfa.totp]\nenroll_enabled = true\nverify_enabled = "env(VERIFY_TOTP)"\n', + ), + { format: "json" }, ); - expect(result.stdout).not.toContain("✗"); + + const data = payloadOf(result) ?? {}; + expect(Reflect.get(data, "errors")).toEqual([]); + expect(Reflect.get(data, "warnings")).toMatchObject([ + { code: "env_unresolved", key: "auth.mfa.totp.verify_enabled" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps a cross-key error that reads no unresolved reference", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.mfa.totp]\nenroll_enabled = true\n[auth.mfa.phone]\nverify_enabled = "env(VERIFY_PHONE)"\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { key: "auth.mfa.totp.enroll_enabled" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("does not warn about an env() list item its owning key's override replaces", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[api]\nschemas = ["env(UNSET_SCHEMA)"]\n'), + { format: "json", env: { SUPABASE_API_SCHEMAS: "public" } }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "warnings")).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("warns about an env() under a pruned table whose gate the document enables", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth.email.smtp]\nenabled = true\npass = "env(SMTP_PASS)"\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "warnings")).toMatchObject([ + { code: "env_unresolved", key: "auth.email.smtp.pass" }, + ]); }).pipe(Effect.provide(BunServices.layer)), ); @@ -865,6 +941,24 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("quotes the empty choice of a codec enum", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[auth]\npassword_requirements = "nope"\n'), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { + code: "invalid_enum", + key: "auth.password_requirements", + reason: + 'expected one of "", letters_digits, lower_upper_letters_digits, lower_upper_letters_digits_symbols', + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("fails an undecryptable env override of a key whose file value is an unset env()", () => Effect.gen(function* () { const result = yield* validate( @@ -923,6 +1017,21 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect.each(["email", "sms"])( + "names auth.%s.enabled by the spelling the document uses", + (provider) => + Effect.gen(function* () { + const result = yield* validate( + tomlFile(`project_id = "demo"\n[auth.${provider}]\nenabled = "invalid"\n`), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { key: `auth.${provider}.enabled`, line: 3 }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("reports a present key with a bad value once, not also as missing", () => Effect.gen(function* () { const result = yield* validate( diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts index 8c780af90a..a6835abb36 100644 --- a/apps/cli/src/commands/config/validate/validate.model.ts +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -34,6 +34,8 @@ export interface ConfigProblem { readonly location?: CliConfigLocation; /** The value comes from the environment, so the config file has no location for it. */ readonly external?: true; + /** A cross-key check raised this from other keys' values, not from the value at `path`. */ + readonly derived?: true; } /** Splits a `remotes..…` path into the remote and the path inside it. */ @@ -133,10 +135,26 @@ export function envReferences(document: unknown): ReadonlyArray { return found; } +/** Whether a cross-key check that reports `path` reads `reference`: an enclosing `enabled` gate, an MFA factor's `verify_enabled`, or a third-party provider's `enabled`. */ +function readsKey(path: ReadonlyArray, reference: ReadonlyArray): boolean { + const last = reference.at(-1); + if (last === "enabled" && atOrBelow(path, reference.slice(0, -1))) return true; + const [root, group, factor, key] = path; + if (root === "auth" && group === "mfa" && key === "enroll_enabled") { + return reference.join("\0") === ["auth", "mfa", factor, "verify_enabled"].join("\0"); + } + return ( + path.join(".") === "auth.third_party" && + last === "enabled" && + reference.length === 4 && + atOrBelow(reference, path) + ); +} + /** * Drops value-level errors that sit at or below a key whose `env(NAME)` reference could not be * resolved, since the literal reference text is what fails, and names the references that caused a - * drop. Errors about the key itself, and values an env variable supplied, always stay. + * drop. A cross-key (`derived`) error also goes when a key its check reads is unresolved. Errors about the key itself, and values an env variable supplied, always stay. */ export function withoutUnresolvedEnvErrors( problems: ReadonlyArray, @@ -156,8 +174,10 @@ export function withoutUnresolvedEnvErrors( return true; } const { path } = problem; - const owners = unresolvedFor(problem.remote).filter((reference) => - atOrBelow(path, reference.path), + const owners = unresolvedFor(problem.remote).filter( + (reference) => + atOrBelow(path, reference.path) || + (problem.derived === true && readsKey(path, reference.path)), ); for (const reference of owners) { blamed.push({ diff --git a/apps/cli/src/config/cli-config-schema-issues.ts b/apps/cli/src/config/cli-config-schema-issues.ts index c9277f4e63..0c4c2fa88f 100644 --- a/apps/cli/src/config/cli-config-schema-issues.ts +++ b/apps/cli/src/config/cli-config-schema-issues.ts @@ -63,7 +63,7 @@ function classify(message: string): Omit { } const choices = ENUM_MESSAGE.exec(message)?.[1] ?.split(" | ") - .map((item) => item.slice(1, -1)); + .map((item) => (item.length === 2 ? item : item.slice(1, -1))); if (choices !== undefined) { const list = choices.join(", "); return { code: "invalid_enum", reason: `expected one of ${list}`, help: `use one of ${list}` }; diff --git a/apps/cli/src/config/cli-config-schema-issues.unit.test.ts b/apps/cli/src/config/cli-config-schema-issues.unit.test.ts index 384e257535..1fe79c985c 100644 --- a/apps/cli/src/config/cli-config-schema-issues.unit.test.ts +++ b/apps/cli/src/config/cli-config-schema-issues.unit.test.ts @@ -63,6 +63,15 @@ describe("flattenSchemaIssues", () => { expect(flat[2]?.help).toBe("use one of fast, slow"); }); + it("quotes an empty enum choice", () => { + const [issue] = flattenSchemaIssues( + schemaError(Schema.Struct({ mode: Schema.Literals(["", "letters"]) }), { mode: "x" }), + ); + + expect(issue?.reason).toBe('expected one of "", letters'); + expect(issue?.help).toBe('use one of "", letters'); + }); + it("returns nothing for a cause that is not a schema error", () => { expect(flattenSchemaIssues(new Error("boom"))).toEqual([]); }); diff --git a/apps/cli/src/config/cli-config-unknown-keys.ts b/apps/cli/src/config/cli-config-unknown-keys.ts index 0f7be45b51..649ab704d1 100644 --- a/apps/cli/src/config/cli-config-unknown-keys.ts +++ b/apps/cli/src/config/cli-config-unknown-keys.ts @@ -14,13 +14,36 @@ export interface ReportableUnknownCliConfigKey { const withoutRemote = (key: ReadonlyArray): ReadonlyArray => key[0] === "remotes" ? key.slice(2) : key; -/** Unknown-key reports stop at the first unknown table, so a document-only table such as `auth.passkey` arrives whole. */ -const isDocumentOnlyKey = (key: ReadonlyArray): boolean => { - const dotted = key.join("."); - return [...cliConfigDocumentOnlyPaths].some( - (known) => known === dotted || known.startsWith(`${dotted}.`) || dotted.startsWith(`${known}.`), - ); -}; +const documentKeySegments: ReadonlyArray> = [ + ...cliConfigDocumentOnlyPaths, +].map((known) => known.split(".")); + +const startsWith = (key: ReadonlyArray, prefix: ReadonlyArray): boolean => + prefix.length <= key.length && prefix.every((segment, index) => key[index] === segment); + +/** Unknown-key reports stop at the first unknown table, so a document-only table such as `auth.passkey` arrives whole and its children are checked here. */ +function unknownWithinDocumentOnly( + document: unknown, + path: ReadonlyArray, +): ReadonlyArray | undefined { + const local = withoutRemote(path); + if (!documentKeySegments.some((known) => startsWith(known, local) || startsWith(local, known))) { + return undefined; + } + const value = getDocumentValue(document, path); + const declared = documentKeySegments.filter((known) => startsWith(known, local)); + if (declared.every((known) => known.length === local.length) || !isDocumentRecord(value)) { + return []; + } + const knownKeys = [ + ...new Set(declared.flatMap((known) => known.slice(local.length, local.length + 1))), + ]; + return Object.keys(value).flatMap((name) => { + const child = [...path, name]; + if (knownKeys.includes(name)) return unknownWithinDocumentOnly(document, child) ?? []; + return [{ path: child, knownKeys }]; + }); +} /** Custom `[auth.external.]` providers have no schema entry, but `start` reads them from the raw document. */ const isCustomAuthProvider = (rawDocument: unknown, key: ReadonlyArray): boolean => { @@ -44,19 +67,17 @@ const documentOnlySiblings = (key: ReadonlyArray): ReadonlyArray }); }; -/** Unknown keys the CLI ignores and should flag, minus document-only tables and custom auth providers. */ +/** Unknown keys the CLI ignores and should flag, minus custom auth providers. */ export function reportableUnknownCliConfigKeys( document: unknown, ): ReadonlyArray { return findUnknownCliConfigKeys(document) - .filter( - ({ path }) => - !isDocumentOnlyKey(withoutRemote(path)) && !isCustomAuthProvider(document, path), - ) - .map(({ path, knownKeys }) => ({ - path, - knownKeys: [...new Set([...knownKeys, ...documentOnlySiblings(path)])], - })); + .filter(({ path }) => !isCustomAuthProvider(document, path)) + .flatMap(({ path, knownKeys }) => { + const nested = unknownWithinDocumentOnly(document, path); + if (nested !== undefined) return nested; + return [{ path, knownKeys: [...new Set([...knownKeys, ...documentOnlySiblings(path)])] }]; + }); } function editDistance(left: string, right: string): number { diff --git a/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts b/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts index ab8ac5a26f..0664042527 100644 --- a/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts +++ b/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts @@ -25,6 +25,23 @@ describe("reportableUnknownCliConfigKeys", () => { ).toEqual([]); }); + it("flags an unknown child of a document-only table", () => { + expect( + reportableUnknownCliConfigKeys({ + auth: { passkey: { enabld: true }, webauthn: { rp_id: "x", rp_idd: "y" } }, + }).map(({ path }) => path.join(".")), + ).toEqual(["auth.passkey.enabld", "auth.webauthn.rp_idd"]); + }); + + it("accepts every registered child of a document-only table", () => { + expect( + reportableUnknownCliConfigKeys({ + db: { password: "x" }, + auth: { passkey: { enabled: true }, webauthn: { rp_id: "x", rp_origins: [] } }, + }), + ).toEqual([]); + }); + it("offers document-only tables as siblings of an unknown auth key", () => { const [found] = reportableUnknownCliConfigKeys({ auth: { passkeys: { enabled: true } } }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 1aa1d9b84c..3dfe9965bf 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -120,6 +120,7 @@ ConfigPushStorageUpdateStatusError ConfigPushWorkdirError ConfigValidateLoadError ConfigValidateOutputFlagUnsupportedError +ConfigValidateProblemsError ConfigValidateWorkdirError ConflictingFunctionDeployFlagsError ConflictingFunctionDownloadFlagsError diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 7f31681a60..7264fb2b2f 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -28,6 +28,7 @@ import { classifyCliErrorActionability, unknownProcessControlledFailureActionability, } from "../shared/telemetry/error-actionability.ts"; +import { ConfigValidateProblemsError } from "../commands/config/validate/validate.errors.ts"; import { DbAdvisorsFailOnError } from "../commands/db/advisors/advisors.errors.ts"; import { DbLintFailOnError } from "../commands/db/lint/lint.errors.ts"; import { @@ -71,11 +72,14 @@ import { /** * Classifies a command that succeeded its Effect but recorded a nonzero exit code through - * ProcessControl. `db lint`/`db advisors` do this in machine mode after a `--fail-on` trigger, to + * ProcessControl. `db lint`/`db advisors`/`config validate` do this in machine mode after a `--fail-on` trigger, to * keep the JSON payload on stdout intact, so their telemetry derives from the same typed error * their text mode raises. */ function processControlledFailureActionability(command: string): CliErrorActionability { + if (command === "config validate") { + return classifyCliErrorActionability(new ConfigValidateProblemsError({ message: "" })); + } if (command === "db lint") { return classifyCliErrorActionability(new DbLintFailOnError({ message: "" })); } diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index 18107bc3cb..6d945bafdd 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -1209,6 +1209,37 @@ describe("withCommandTelemetry", () => { ); }); + it.live("classifies a config validate run that reports problems as a config error", () => { + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + + return Effect.gen(function* () { + const pc = yield* ProcessControl; + yield* pc.setExitCode(1); + }).pipe( + withCommandTelemetry(), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["config", "validate"]) }), + commandRuntimeLayer(["config", "validate"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:ConfigValidateProblemsError", + }); + }), + ), + ); + }); + it.live("classifies db advisors machine-mode fail-on like its typed text-mode error", () => { const analytics = mockContextualAnalytics(); const processControl = mockProcessControl(); diff --git a/packages/config/src/config-locate.ts b/packages/config/src/config-locate.ts index d3f819d358..d432b362da 100644 --- a/packages/config/src/config-locate.ts +++ b/packages/config/src/config-locate.ts @@ -296,7 +296,7 @@ function childOf(node: Node, segment: string): { entry: Node; value: Node } | un return value === undefined ? undefined : { entry: value, value }; } if (node.type !== "object") return undefined; - const entry = node.children?.find((property) => property.children?.[0]?.value === segment); + const entry = node.children?.findLast((property) => property.children?.[0]?.value === segment); const value = entry?.children?.[1]; return entry === undefined || value === undefined ? undefined : { entry, value }; } diff --git a/packages/config/src/config-locate.unit.test.ts b/packages/config/src/config-locate.unit.test.ts index cbda9b0a1a..27041a819e 100644 --- a/packages/config/src/config-locate.unit.test.ts +++ b/packages/config/src/config-locate.unit.test.ts @@ -118,6 +118,12 @@ describe("locateCliConfigKey (toml)", () => { }); describe("locateCliConfigKey (jsonc)", () => { + test("locates the last of a duplicated key, as the parser keeps", () => { + const source = '{ "api": { "port": 1, "port": 2 } }'; + const location = locateCliConfigKey(source, "json", ["api", "port"], "value"); + expect(location).toMatchObject({ line: 1, column: source.lastIndexOf("2") + 1 }); + }); + const jsonc = [ "// comment", "{", From 93200c8bf669332c4dc6b341f443813993a4363e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 12 Nov 2026 10:04:46 +0000 Subject: [PATCH 11/12] fix(cli): scope config validate env() suppression to the keys each check reads - Cross-key validation issues declare the keys they read; an unresolved env() suppresses only issues that read it, so independent errors under a gated table still fail. - References replaced by a SUPABASE_* override no longer suppress errors. - Suppression runs per pass before deduplication, so a remote's own error survives. - An unset env() on an `enabled` key reads its gate from the parent table. - The content_path help points at an existing file under the project root. --- .../src/command-internal/config-validate.ts | 47 +++++++++++-- .../command-internal/local-config-values.ts | 2 +- .../config/validate/validate.handler.ts | 66 +++++++++++-------- .../validate/validate.integration.test.ts | 56 ++++++++++++++++ .../config/validate/validate.model.ts | 24 ++----- apps/cli/src/telemetry/command-telemetry.ts | 7 +- 6 files changed, 141 insertions(+), 61 deletions(-) diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index e3c3898ebd..1303d9d987 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -284,6 +284,8 @@ export interface ConfigValidationIssue { readonly code: ConfigValidationIssueCode; readonly reason: string; readonly help?: string; + /** Config keys whose values this issue depends on besides the one it names; a table read covers every key under it. */ + readonly reads?: ReadonlyArray>; } const lastSegment = (path: string): string => path.split(".").at(-1) ?? path; @@ -328,6 +330,7 @@ export function missingIssue( path, message, code: "missing_required", + reads: [gate.split(".")], reason: `required when ${gate} = true`, help: `set ${lastSegment(path)}, or set ${parentOf(gate) === parentOf(path) ? lastSegment(gate) : gate} = false`, }; @@ -363,10 +366,17 @@ export function collectConfigValidationIssues( const missing = (path: string, message: string): void => { report(missingIssue(path, message)); }; + const readingFrom = (start: number, key: ReadonlyArray): void => { + for (let index = start; index < issues.length; index += 1) { + const issue = issues[index]; + if (issue !== undefined) issues[index] = { ...issue, reads: [...(issue.reads ?? []), key] }; + } + }; if (input.projectId !== undefined && input.projectId.length === 0) { missing("project_id", "Missing required field in config: project_id"); } + const apiStart = issues.length; if (input.api?.enabled) { const api = input.api; if (api.port === 0) missing("api.port", "Missing required field in config: api.port"); @@ -376,6 +386,8 @@ export function collectConfigValidationIssues( } } + readingFrom(apiStart, ["api", "enabled"]); + if (input.db.port === 0) { missing("db.port", "Missing required field in config: db.port"); } @@ -415,6 +427,7 @@ export function collectConfigValidationIssues( } } + const studioStart = issues.length; if (input.studio?.enabled) { if (input.studio.port === 0) { missing("studio.port", "Missing required field in config: studio.port"); @@ -433,10 +446,13 @@ export function collectConfigValidationIssues( } } + readingFrom(studioStart, ["studio", "enabled"]); + if (input.localSmtp?.enabled && input.localSmtp.port === 0) { missing("local_smtp.port", "Missing required field in config: local_smtp.port"); } + const authStart = issues.length; if (input.auth !== undefined) { const auth = input.auth; @@ -502,11 +518,11 @@ export function collectConfigValidationIssues( } // Caller pre-filters to enabled-only hooks, pre-ordered by the fixed hook-type sequence. - for (const hook of auth.hooks) { + const checkHook = (hook: (typeof auth.hooks)[number]): void => { const hookPath = `auth.hook.${hook.type}`; if (hook.uri.length === 0) { missing(`${hookPath}.uri`, `Missing required field in config: auth.hook.${hook.type}.uri`); - continue; + return; } // Uses `parseUrl`'s stricter semantics (the same port used for `studio.api_url` // above) so a malformed URI like an unterminated IPv6 host (`http://[::1`) fails the @@ -523,7 +539,7 @@ export function collectConfigValidationIssues( "use an http(s):// or pg-functions:// URI", ), ); - continue; + return; } if (scheme === "http" || scheme === "https") { if (hook.secrets.length === 0) { @@ -562,6 +578,11 @@ export function collectConfigValidationIssues( ), ); } + }; + for (const hook of auth.hooks) { + const hookStart = issues.length; + checkHook(hook); + readingFrom(hookStart, ["auth", "hook", hook.type, "enabled"]); } for (const factor of auth.mfa) { @@ -574,6 +595,7 @@ export function collectConfigValidationIssues( `set auth.mfa.${factor.label}.verify_enabled = true, or disable enroll_enabled`, ), ); + readingFrom(issues.length - 1, ["auth", "mfa", factor.label, "verify_enabled"]); } } @@ -605,6 +627,7 @@ export function collectConfigValidationIssues( const base = `auth.third_party.${thirdParty.provider === "cognito" ? "aws_cognito" : thirdParty.provider}`; const missingMessage = (field: string) => `Invalid config: auth.third_party.${thirdParty.provider} is enabled but without a ${field}.`; + const providerStart = issues.length; switch (thirdParty.provider) { case "firebase": { if (thirdParty.requiredField.length === 0) { @@ -651,6 +674,7 @@ export function collectConfigValidationIssues( break; } } + readingFrom(providerStart, [...base.split("."), "enabled"]); } if (auth.thirdParty.length > 1) { report( @@ -661,8 +685,13 @@ export function collectConfigValidationIssues( "enable one auth.third_party provider and set enabled = false on the others", ), ); + for (const thirdParty of auth.thirdParty) { + const provider = thirdParty.provider === "cognito" ? "aws_cognito" : thirdParty.provider; + readingFrom(issues.length - 1, ["auth", "third_party", provider, "enabled"]); + } } } + readingFrom(authStart, ["auth", "enabled"]); for (const slug of input.functionSlugs) { if (!FUNCTION_SLUG_PATTERN.test(slug)) { @@ -953,18 +982,22 @@ export function collectAuthSmsProviderIssues( sms: CliConfig["auth"]["sms"], ): ReadonlyArray { const issues: Array = []; + const smsOrder = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"]; const requireFields = ( provider: string, fields: ReadonlyArray, ): void => { for (const [field, value] of fields) { if (value === undefined || value.length === 0) { - issues.push( - missingIssue( + issues.push({ + ...missingIssue( `auth.sms.${provider}.${field}`, `Missing required field in config: auth.sms.${provider}.${field}`, ), - ); + reads: smsOrder + .slice(0, smsOrder.indexOf(provider) + 1) + .map((earlier) => ["auth", "sms", earlier, "enabled"]), + }); } } }; @@ -1025,6 +1058,7 @@ export function collectAuthExternalProviderIssues( `Missing required field in config: auth.external.${name}.client_id`, ), segments: ["auth", "external", name, "client_id"], + reads: [["auth", "external", name, "enabled"]], }); } if ( @@ -1038,6 +1072,7 @@ export function collectAuthExternalProviderIssues( `Missing required field in config: auth.external.${name}.secret`, ), segments: ["auth", "external", name, "secret"], + reads: [["auth", "external", name, "enabled"]], }); } } diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index e5bbb2f18a..660415d2d3 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -195,7 +195,7 @@ const SIGNED_KEY_ISSUE: IssueMeta = { const CONTENT_PATH_ISSUE: IssueMeta = { reason: "content_path is not a readable file inside the project", - help: "point content_path at an existing file under the project root, or use `content` instead of `content_path`", + help: "point content_path at an existing file under the project root", }; /** Without a collector `effect` runs unchanged; with one a failure is recorded at `path` and `fallback` stands in. */ diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index fe4e656274..cb957fcfe1 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -318,7 +318,7 @@ function semanticProblem( const path = issue.segments ?? issue.path.split("."); const fromEnv = origin.tier === "shell" || origin.tier === "projectEnv"; return problem(code, { - derived: true as const, + ...(issue.reads === undefined ? {} : { reads: issue.reads }), ...(issue.path === "" ? {} : { path }), reason: issue.reason, ...(fromEnv @@ -713,33 +713,50 @@ export const configValidate = Effect.fn("config.validate")(function* () { unresolvedSet.has(reference) && (reference.remote === remote || (remote !== undefined && inheritedBy(reference, remote))), ); - const { kept, blamed } = withoutUnresolvedEnvErrors( - dedupeProblems( - [ - ...notices, - ...base.problems, - ...(base.resolved?.remoteFailures ?? []) - .filter( - (failure) => - remoteNames.includes(failure.remote) && - !unresolvedProjectIdRemotes.has(failure.remote), - ) - .map((failure) => remoteFailureProblem(failure, file)), - ...remotePasses.flatMap((item) => item.problems), - ], - remoteWrites, - ), - unresolvedFor, - ); - const isGateOpen = (reference: EnvReference, context: string | undefined): boolean => { const owner = passes.get(context)?.resolved; const fellBack = owner?.pruned.some((path) => path.every((s, i) => s === reference.path[i])); const configs = fellBack ? [owner?.loaded.document] : [owner?.materialized.config, owner?.loaded.document]; - return nearestEnabledGate(configs, reference.path) !== false; + const scope = + reference.path.at(-1) === "enabled" ? reference.path.slice(0, -1) : reference.path; + return nearestEnabledGate(configs, scope) !== false; }; + const isOverridden = (reference: EnvReference, context: string | undefined): boolean => { + const materialized = passes.get(context)?.resolved?.materialized; + return reference.path.some((_, index) => { + const origin = materialized?.originAt(displayPath(reference.path.slice(0, index + 1))); + return origin?.tier === "shell" || origin?.tier === "projectEnv" || origin?.tier === "flag"; + }); + }; + const blamed: Array<{ readonly reference: EnvReference; readonly remote?: string }> = []; + const suppressed = ( + problems: ReadonlyArray, + context: string | undefined, + ): ReadonlyArray => { + const result = withoutUnresolvedEnvErrors(problems, () => + unresolvedFor(context).filter((reference) => !isOverridden(reference, context)), + ); + blamed.push(...result.blamed); + return result.kept; + }; + const kept = dedupeProblems( + [ + ...notices, + ...suppressed(base.problems, undefined), + ...(base.resolved?.remoteFailures ?? []) + .filter( + (failure) => + remoteNames.includes(failure.remote) && + !unresolvedProjectIdRemotes.has(failure.remote), + ) + .map((failure) => remoteFailureProblem(failure, file)), + ...remotePasses.flatMap((item, index) => suppressed(item.problems, checkedRemotes[index])), + ], + remoteWrites, + ); + const isRelevantIn = (reference: EnvReference, context: string | undefined): boolean => CLI_CONFIG_OPTIONAL_ENV_PATHS[displayPath(reference.path)] === undefined && isGateOpen(reference, context); @@ -751,13 +768,6 @@ export const configValidate = Effect.fn("config.validate")(function* () { help: `set ${reference.name} in your shell or supabase/.env (SUPABASE_ENV= also reads supabase/.env.)`, ...(remote === undefined ? {} : { remote }), }); - const isOverridden = (reference: EnvReference, context: string | undefined): boolean => { - const materialized = passes.get(context)?.resolved?.materialized; - return reference.path.some((_, index) => { - const origin = materialized?.originAt(displayPath(reference.path.slice(0, index + 1))); - return origin?.tier === "shell" || origin?.tier === "projectEnv" || origin?.tier === "flag"; - }); - }; const envWarnings = unresolved.flatMap((reference) => { const warns = (context: string | undefined) => !isOverridden(reference, context) && diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index 79af6a7e86..b9fcfa111c 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -846,6 +846,62 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("keeps a cross-key error whose check does not read the unresolved gate", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[edge_runtime]\nenabled = "env(ENABLE_EDGE)"\ndeno_version = 99\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { key: "edge_runtime.deno_version" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps the error a SUPABASE_* override of an unresolved gate leaves in place", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile('project_id = "demo"\n[api]\nenabled = "env(ENABLE_API)"\nport = 0\n'), + { format: "json", env: { SUPABASE_API_ENABLED: "true" } }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([{ key: "api.port" }]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps a remote's own error when the base copy is suppressed", () => + Effect.gen(function* () { + const result = yield* validate( + tomlFile( + 'project_id = "demo"\n[auth]\nenabled = "env(ENABLE_AUTH)"\nsite_url = ""\n[remotes.prod]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.prod.auth]\nenabled = true\n', + ), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "errors")).toMatchObject([ + { key: "remotes.prod.auth.site_url", remote: "prod" }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect.each(["auth.hook.custom_access_token", "api.tls", "auth.email.smtp"])( + "warns about an unset env() on the %s enabled flag itself", + (path) => + Effect.gen(function* () { + const result = yield* validate( + tomlFile(`project_id = "demo"\n[${path}]\nenabled = "env(FLAG_ON)"\n`), + { format: "json" }, + ); + + expect(Reflect.get(payloadOf(result) ?? {}, "warnings")).toMatchObject([ + { code: "env_unresolved", key: `${path}.enabled` }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("still reports a malformed literal remote project_id", () => Effect.gen(function* () { const result = yield* validate( diff --git a/apps/cli/src/commands/config/validate/validate.model.ts b/apps/cli/src/commands/config/validate/validate.model.ts index a6835abb36..4c5fb24b6d 100644 --- a/apps/cli/src/commands/config/validate/validate.model.ts +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -34,8 +34,8 @@ export interface ConfigProblem { readonly location?: CliConfigLocation; /** The value comes from the environment, so the config file has no location for it. */ readonly external?: true; - /** A cross-key check raised this from other keys' values, not from the value at `path`. */ - readonly derived?: true; + /** Keys, besides `path`, whose values the check behind this problem read. */ + readonly reads?: ReadonlyArray>; } /** Splits a `remotes..…` path into the remote and the path inside it. */ @@ -135,26 +135,10 @@ export function envReferences(document: unknown): ReadonlyArray { return found; } -/** Whether a cross-key check that reports `path` reads `reference`: an enclosing `enabled` gate, an MFA factor's `verify_enabled`, or a third-party provider's `enabled`. */ -function readsKey(path: ReadonlyArray, reference: ReadonlyArray): boolean { - const last = reference.at(-1); - if (last === "enabled" && atOrBelow(path, reference.slice(0, -1))) return true; - const [root, group, factor, key] = path; - if (root === "auth" && group === "mfa" && key === "enroll_enabled") { - return reference.join("\0") === ["auth", "mfa", factor, "verify_enabled"].join("\0"); - } - return ( - path.join(".") === "auth.third_party" && - last === "enabled" && - reference.length === 4 && - atOrBelow(reference, path) - ); -} - /** * Drops value-level errors that sit at or below a key whose `env(NAME)` reference could not be * resolved, since the literal reference text is what fails, and names the references that caused a - * drop. A cross-key (`derived`) error also goes when a key its check reads is unresolved. Errors about the key itself, and values an env variable supplied, always stay. + * drop. An error also goes when a key its check `reads` is unresolved. Errors about the key itself, and values an env variable supplied, always stay. */ export function withoutUnresolvedEnvErrors( problems: ReadonlyArray, @@ -177,7 +161,7 @@ export function withoutUnresolvedEnvErrors( const owners = unresolvedFor(problem.remote).filter( (reference) => atOrBelow(path, reference.path) || - (problem.derived === true && readsKey(path, reference.path)), + (problem.reads ?? []).some((read) => atOrBelow(reference.path, read)), ); for (const reference of owners) { blamed.push({ diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 7264fb2b2f..1a12e374a9 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -70,12 +70,7 @@ import { type CommandTelemetryAttributeValues, } from "./command-telemetry-attributes.ts"; -/** - * Classifies a command that succeeded its Effect but recorded a nonzero exit code through - * ProcessControl. `db lint`/`db advisors`/`config validate` do this in machine mode after a `--fail-on` trigger, to - * keep the JSON payload on stdout intact, so their telemetry derives from the same typed error - * their text mode raises. - */ +/** Classifies a command that records a nonzero exit code through ProcessControl instead of failing: `db lint`/`db advisors` after a machine-mode `--fail-on` trigger, and `config validate` when it reports problems. */ function processControlledFailureActionability(command: string): CliErrorActionability { if (command === "config validate") { return classifyCliErrorActionability(new ConfigValidateProblemsError({ message: "" })); From ece2fc48638dd932d0e3cfce5ed49abb84a8eb4c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 12 Nov 2026 11:20:46 +0000 Subject: [PATCH 12/12] fix(cli): declare enabled gates on config file reads and provider checks - File-read issues (api.tls files, signing keys, email template content) declare the enabled keys their read runs behind. - Auth-gated sms and external provider issues declare auth.enabled. - Schema missing_required problems carry their gate. --- .../command-internal/local-config-values.ts | 66 ++++++-- .../config/validate/validate.handler.ts | 3 +- .../validate/validate.integration.test.ts | 148 ++++++++++++++++++ 3 files changed, 205 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index 660415d2d3..9c85199348 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -185,8 +185,23 @@ interface IssueMeta { readonly code?: ConfigValidationIssue["code"]; } -const issueFrom = (path: string, message: string, meta: IssueMeta): ConfigValidationIssue => - invalidIssue(path, message, meta.reason, meta.help, meta.code); +type Reads = ReadonlyArray>; + +const issueFrom = ( + path: string, + message: string, + meta: IssueMeta, + reads?: Reads, +): ConfigValidationIssue => ({ + ...invalidIssue(path, message, meta.reason, meta.help, meta.code), + ...(reads === undefined ? {} : { reads }), +}); + +const readingKey = ( + issues: ReadonlyArray, + key: ReadonlyArray, +): ReadonlyArray => + issues.map((issue) => ({ ...issue, reads: [...(issue.reads ?? []), key] })); const SIGNED_KEY_ISSUE: IssueMeta = { reason: "could not be generated", @@ -198,19 +213,20 @@ const CONTENT_PATH_ISSUE: IssueMeta = { help: "point content_path at an existing file under the project root", }; -/** Without a collector `effect` runs unchanged; with one a failure is recorded at `path` and `fallback` stands in. */ +/** Without a collector `effect` runs unchanged; with one a failure is recorded at `path`, declaring the config keys the read runs behind in `reads`, and `fallback` stands in. */ const collecting = ( collector: Array | undefined, path: string, effect: Effect.Effect, fallback: A, meta: IssueMeta, + reads?: Reads, ): Effect.Effect => collector === undefined ? effect : effect.pipe( Effect.catch((error: E) => { - collector.push(issueFrom(path, error.message, meta)); + collector.push(issueFrom(path, error.message, meta, reads)); return Effect.succeed(fallback); }), ); @@ -528,6 +544,7 @@ const readAuthEmailTemplateContent = Effect.fnUntraced(function* ( readEntry("template", name, tmpl), undefined, CONTENT_PATH_ISSUE, + [["auth", "enabled"]], ); } for (const [name, tmpl] of Object.entries(email.notification)) { @@ -538,6 +555,10 @@ const readAuthEmailTemplateContent = Effect.fnUntraced(function* ( readEntry("notification", name, tmpl), undefined, CONTENT_PATH_ISSUE, + [ + ["auth", "enabled"], + ["auth", "email", "notification", name, "enabled"], + ], ); } }); @@ -908,10 +929,20 @@ const resolveLocalConfigValuesWith = Effect.fnUntraced(function* ( ["key", "api.tls.key_path", apiTlsKeyPath], ] as const) { if (configured === undefined || configured.length === 0) continue; - yield* collecting(collector, key, readApiTlsFile(kind, workdir, configured), undefined, { - reason: `${kind === "cert" ? "certificate" : "private key"} file is not readable`, - help: "point it at an existing file, relative to the supabase/ directory", - }); + yield* collecting( + collector, + key, + readApiTlsFile(kind, workdir, configured), + undefined, + { + reason: `${kind === "cert" ? "certificate" : "private key"} file is not readable`, + help: "point it at an existing file, relative to the supabase/ directory", + }, + [ + ["api", "enabled"], + ["api", "tls", "enabled"], + ], + ); } } } @@ -995,6 +1026,7 @@ const resolveLocalConfigValuesWith = Effect.fnUntraced(function* ( reason: "signing keys file is missing, unreadable or invalid", help: "point signing_keys_path at a JSON array of JWKs, relative to the supabase/ directory", }, + [["auth", "enabled"]], )) ?? [DEFAULT_SIGNING_KEY])[0] : undefined; // This block only accumulates the inputs passkey/webauthn/hook/mfa/email/smtp/third_party @@ -1012,6 +1044,7 @@ const resolveLocalConfigValuesWith = Effect.fnUntraced(function* ( attempt(() => rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled")), false, { reason: "expected a boolean", help: "use true or false", code: "invalid_type" }, + [["auth", "enabled"]], )); const rpId = asString(webauthnDoc?.["rp_id"]); const rpOrigins = rawOrigins(webauthnDoc?.["rp_origins"]); @@ -1078,6 +1111,7 @@ const resolveLocalConfigValuesWith = Effect.fnUntraced(function* ( attempt(() => resolveThirdPartyProviders(config.auth.third_party)), [], { reason: "invalid third-party provider settings" }, + [["auth", "enabled"]], ), }; } @@ -1166,10 +1200,20 @@ const resolveLocalConfigValuesWith = Effect.fnUntraced(function* ( } else { collector.push(...collectConfigValidationIssues(input)); if (authEnabled) { - collector.push(...collectAuthSmsProviderIssues(resolveAuthSms(config.auth.sms))); + const externalFailures: Array = []; + const external = resolveAuthExternalProviders( + authDocument, + config.auth.external, + externalFailures, + ); collector.push( - ...collectAuthExternalProviderIssues( - resolveAuthExternalProviders(authDocument, config.auth.external, collector), + ...readingKey( + [ + ...collectAuthSmsProviderIssues(resolveAuthSms(config.auth.sms)), + ...externalFailures, + ...collectAuthExternalProviderIssues(external), + ], + ["auth", "enabled"], ), ); } diff --git a/apps/cli/src/commands/config/validate/validate.handler.ts b/apps/cli/src/commands/config/validate/validate.handler.ts index cb957fcfe1..00712e23a3 100644 --- a/apps/cli/src/commands/config/validate/validate.handler.ts +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -264,11 +264,12 @@ const schemaProblems = ( } return flat.map((issue) => { const { path } = issue; - const detail = + const detail: Pick = issue.code === "missing_required" && path.length > 0 ? missingIssue(displayPath(path), "") : { reason: issue.reason, help: issue.help }; return problem(issue.code, { + ...(detail.reads === undefined ? {} : { reads: detail.reads }), ...(path.length === 0 ? {} : { path }), reason: detail.reason, ...(detail.help === undefined ? {} : { help: detail.help }), diff --git a/apps/cli/src/commands/config/validate/validate.integration.test.ts b/apps/cli/src/commands/config/validate/validate.integration.test.ts index b9fcfa111c..ce12460a32 100644 --- a/apps/cli/src/commands/config/validate/validate.integration.test.ts +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -902,6 +902,154 @@ describe("config validate integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + const GATED_PRODUCERS: ReadonlyArray<{ + readonly name: string; + readonly gate: string; + readonly error: string; + readonly toml: (gate: string) => string; + }> = [ + { + name: "api required fields", + gate: "api.enabled", + error: "api.port", + toml: (gate) => `[api]\nenabled = ${gate}\nport = 0\n`, + }, + { + name: "api.tls required paths", + gate: "api.tls.enabled", + error: "api.tls.cert_path", + toml: (gate) => `[api.tls]\nenabled = ${gate}\nkey_path = "k.key"\n`, + }, + { + name: "api.tls file reads behind api.enabled", + gate: "api.enabled", + error: "api.tls.cert_path", + toml: (gate) => + `[api]\nenabled = ${gate}\n[api.tls]\nenabled = true\ncert_path = "missing.pem"\nkey_path = "missing.key"\n`, + }, + { + name: "api.tls file reads behind api.tls.enabled", + gate: "api.tls.enabled", + error: "api.tls.cert_path", + toml: (gate) => + `[api.tls]\nenabled = ${gate}\ncert_path = "missing.pem"\nkey_path = "missing.key"\n`, + }, + { + name: "studio required fields", + gate: "studio.enabled", + error: "studio.port", + toml: (gate) => `[studio]\nenabled = ${gate}\nport = 0\n`, + }, + { + name: "local_smtp required port", + gate: "local_smtp.enabled", + error: "local_smtp.port", + toml: (gate) => `[local_smtp]\nenabled = ${gate}\nport = 0\n`, + }, + { + name: "auth required fields", + gate: "auth.enabled", + error: "auth.site_url", + toml: (gate) => `[auth]\nenabled = ${gate}\nsite_url = ""\n`, + }, + { + name: "auth signing keys read", + gate: "auth.enabled", + error: "auth.signing_keys_path", + toml: (gate) => `[auth]\nenabled = ${gate}\nsigning_keys_path = "missing.json"\n`, + }, + { + name: "auth email template content read", + gate: "auth.enabled", + error: "auth.email.template.invite.content_path", + toml: (gate) => + `[auth]\nenabled = ${gate}\n[auth.email.template.invite]\ncontent_path = "missing.html"\n`, + }, + { + name: "auth external provider", + gate: "auth.enabled", + error: "auth.external.myidp.secret", + toml: (gate) => + `[auth]\nenabled = ${gate}\n[auth.external.myidp]\nenabled = true\nclient_id = "client"\n`, + }, + { + name: "auth email notification content read", + gate: "auth.email.notification.password_changed.enabled", + error: "auth.email.notification.password_changed.content_path", + toml: (gate) => + `[auth.email.notification.password_changed]\nenabled = ${gate}\ncontent_path = "missing.html"\n`, + }, + { + name: "auth.email.smtp required fields", + gate: "auth.email.smtp.enabled", + error: "auth.email.smtp.host", + toml: (gate) => `[auth.email.smtp]\nenabled = ${gate}\n`, + }, + { + name: "auth.captcha required fields", + gate: "auth.captcha.enabled", + error: "auth.captcha.provider", + toml: (gate) => `[auth.captcha]\nenabled = ${gate}\n`, + }, + { + name: "auth.hook uri", + gate: "auth.hook.custom_access_token.enabled", + error: "auth.hook.custom_access_token.uri", + toml: (gate) => `[auth.hook.custom_access_token]\nenabled = ${gate}\nuri = ""\n`, + }, + { + name: "auth.third_party required field", + gate: "auth.third_party.firebase.enabled", + error: "auth.third_party.firebase.project_id", + toml: (gate) => `[auth.third_party.firebase]\nenabled = ${gate}\n`, + }, + { + name: "auth.sms provider", + gate: "auth.sms.twilio.enabled", + error: "auth.sms.twilio.account_sid", + toml: (gate) => `[auth.sms.twilio]\nenabled = ${gate}\n`, + }, + { + name: "auth.external provider", + gate: "auth.external.myidp.enabled", + error: "auth.external.myidp.secret", + toml: (gate) => `[auth.external.myidp]\nenabled = ${gate}\nclient_id = "client"\n`, + }, + { + name: "auth.passkey webauthn", + gate: "auth.passkey.enabled", + error: "auth.webauthn", + toml: (gate) => `[auth.passkey]\nenabled = ${gate}\n`, + }, + { + name: "analytics bigquery fields", + gate: "analytics.enabled", + error: "analytics.gcp_project_id", + toml: (gate) => `[analytics]\nenabled = ${gate}\nbackend = "bigquery"\n`, + }, + ]; + + it.effect.each(GATED_PRODUCERS)( + "gates $name behind $gate: an unset env() warns, a literal true fails", + ({ gate, error, toml }) => + Effect.gen(function* () { + const unset = yield* validate(tomlFile(`project_id = "demo"\n${toml('"env(GATE_ON)"')}`), { + format: "json", + }); + const open = yield* validate(tomlFile(`project_id = "demo"\n${toml("true")}`), { + format: "json", + }); + + expect(payloadOf(unset)).toMatchObject({ valid: true, errors: [] }); + expect(Reflect.get(payloadOf(unset) ?? {}, "warnings")).toContainEqual( + expect.objectContaining({ code: "env_unresolved", key: gate }), + ); + expect(Reflect.get(payloadOf(open) ?? {}, "errors")).toContainEqual( + expect.objectContaining({ key: error }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("still reports a malformed literal remote project_id", () => Effect.gen(function* () { const result = yield* validate(