diff --git a/apps/cli/bench/workloads.pr.json b/apps/cli/bench/workloads.pr.json index d735b5d552..1595d9219a 100644 --- a/apps/cli/bench/workloads.pr.json +++ b/apps/cli/bench/workloads.pr.json @@ -5,6 +5,7 @@ { "id": "init", "args": ["init"], "cwdSetup": "none" }, { "id": "migration-new", "args": ["migration", "new", "bench"], "cwdSetup": "init" }, { "id": "config-convert", "args": ["config", "convert"], "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..beaf9e1e11 --- /dev/null +++ b/apps/cli/docs/supabase/config/validate.md @@ -0,0 +1,92 @@ +# 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 key path and `file:line:column`. 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 `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. +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. Everything from stage 5 on is reported together. + +## Secrets + +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 + +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 + 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 4668a5f7c3..dfa2ce8807 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`, `config-convert` | 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-convert`, `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 26b1b2b95b..9a96a0218a 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; @@ -333,9 +355,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 cf0e39a20a..059ba45672 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -248,86 +248,225 @@ 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; + /** 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; + 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; +const parentOf = (path: string): string => path.split(".").slice(0, -1).join("."); + +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", + reads: [gate.split(".")], + reason: `required when ${gate} = true`, + help: `set ${lastSegment(path)}, or set ${parentOf(gate) === parentOf(path) ? lastSegment(gate) : 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 = [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)); + }; + 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) { - 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. + const apiStart = issues.length; 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}`); } } + readingFrom(apiStart, ["api", "enabled"]); + 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 (input.db.majorVersion === 13) { - throw new ConfigValidateError( - "Postgres version 13.x is unsupported. To use the CLI, upgrade your project to Postgres 17, then set db.major_version = 17 in your project config: https://supabase.com/docs/guides/platform/upgrading.", + } else if (input.db.majorVersion === 13) { + report( + invalidIssue( + "db.major_version", + "Postgres version 13.x is unsupported. To use the CLI, upgrade your project to Postgres 17, then set db.major_version = 17 in your project config: https://supabase.com/docs/guides/platform/upgrading.", + "unsupported Postgres version", + "upgrade the project to Postgres 17 and set db.major_version = 17; see https://supabase.com/docs/guides/platform/upgrading", + ), ); - } - if (![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", + ), + segments: ["storage", "buckets", name], + }); } } + const studioStart = issues.length; 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", + ), + ); } } + readingFrom(studioStart, ["studio", "enabled"]); + 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"); } + const authStart = issues.length; 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 @@ -340,16 +479,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"); } } } @@ -359,24 +506,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 checkHook = (hook: (typeof auth.hooks)[number]): void => { + 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`); + 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 @@ -385,39 +540,71 @@ 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", + ), + ); + return; } 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 hook of auth.hooks) { + const hookStart = issues.length; + checkHook(hook); + readingFrom(hookStart, ["auth", "hook", hook.type, "enabled"]); } 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`, + ), ); + readingFrom(issues.length - 1, ["auth", "mfa", factor.label, "verify_enabled"]); } } @@ -425,22 +612,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}`, + ); + } } } @@ -448,83 +633,102 @@ 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}.`; + const providerStart = issues.length; 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; } } + readingFrom(providerStart, [...base.split("."), "enabled"]); } 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 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)) { - 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", + ), + segments: ["functions", slug], + }); } } 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", + ), ); } @@ -537,39 +741,70 @@ 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") { + 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) { - throw new ConfigValidateError("Missing required field in config: analytics.gcp_project_id"); + missingForBigquery("analytics.gcp_project_id", "gcp_project_id"); } if (input.analytics.gcpProjectNumber.length === 0) { - throw new ConfigValidateError( - "Missing required field in config: analytics.gcp_project_number", - ); + missingForBigquery("analytics.gcp_project_number", "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 { @@ -749,71 +984,114 @@ export function apiTlsPairErrorMessage(certPath: string, keyPath: string, cause: } /** - * 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 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( + `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"]), + }); + } } - } + }; 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`, + ), + segments: ["auth", "external", name, "client_id"], + reads: [["auth", "external", name, "enabled"]], + }); } if ( name !== "apple" && 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`, + ), + segments: ["auth", "external", name, "secret"], + reads: [["auth", "external", name, "enabled"]], + }); } } + 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 60f0ae0e27..c0419f6909 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"]) { @@ -1400,3 +1407,160 @@ 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("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([]); + }); + + 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..9c85199348 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,58 @@ 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"]; +} + +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", + 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", +}; + +/** 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, reads)); + 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 +242,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 +406,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)) + .readFile(resolveApiTlsPath(path, workdir, configured)) .pipe( Effect.mapError( - (cause) => new ConfigValidateError(apiTlsCertReadErrorMessage(fsErrorCause(cause))), - ), - ); - yield* fs - .readFile(resolveApiTlsPath(path, workdir, keyPath)) - .pipe( - Effect.mapError( - (cause) => new ConfigValidateError(apiTlsKeyReadErrorMessage(fsErrorCause(cause))), + (cause) => + new ConfigValidateError( + kind === "cert" + ? apiTlsCertReadErrorMessage(fsErrorCause(cause)) + : apiTlsKeyReadErrorMessage(fsErrorCause(cause)), + ), ), ); }); @@ -439,56 +505,61 @@ 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, + [["auth", "enabled"]], + ); } 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, + [ + ["auth", "enabled"], + ["auth", "email", "notification", name, "enabled"], + ], ); - if (path === undefined) continue; - yield* fs - .readFile(path) - .pipe( - Effect.mapError( - (cause) => - new ConfigValidateError( - emailContentPathReadErrorMessage("notification", name, fsErrorCause(cause)), - ), - ), - ); } }); @@ -670,6 +741,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 +771,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 +783,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 +814,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 +825,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 +861,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 +872,47 @@ 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", + "check the files and settings this config section reads", + ), + ); + }), + ), + ); + 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 +921,73 @@ 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", + }, + [ + ["api", "enabled"], + ["api", "tls", "enabled"], + ], + ); + } + } } 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 +995,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 +1017,35 @@ 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", + }, + [["auth", "enabled"]], + )) ?? [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" }, + [["auth", "enabled"]], + )); const rpId = asString(webauthnDoc?.["rp_id"]); const rpOrigins = rawOrigins(webauthnDoc?.["rp_origins"]); const passkey: PasskeyInput | undefined = passkeyEnabled @@ -872,7 +1079,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 +1105,32 @@ 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" }, + [["auth", "enabled"]], + ), }; } 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 +1187,53 @@ 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) { + const externalFailures: Array = []; + const external = resolveAuthExternalProviders( + authDocument, + config.auth.external, + externalFailures, + ); + collector.push( + ...readingKey( + [ + ...collectAuthSmsProviderIssues(resolveAuthSms(config.auth.sms)), + ...externalFailures, + ...collectAuthExternalProviderIssues(external), + ], + ["auth", "enabled"], + ), + ); + } } - 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 b3b2856841..62ef529eff 100644 --- a/apps/cli/src/commands/config/config.command.ts +++ b/apps/cli/src/commands/config/config.command.ts @@ -3,6 +3,7 @@ import { configConvertCommand } from "./convert/convert.command.ts"; 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."), @@ -12,5 +13,6 @@ export const configCommand = Command.make("config").pipe( 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..a30b70ff76 --- /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; 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 | +| `/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 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 + +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`, 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` + +`{"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` + +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 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.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..7463932f91 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.errors.ts @@ -0,0 +1,42 @@ +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; + } +} + +/** 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 new file mode 100644 index 0000000000..a7eee12781 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.format.ts @@ -0,0 +1,190 @@ +import { escapeControlCharacters } from "../../../shared/output/escape-control-characters.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 { + 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" + | "removed_env" + | "schema_limit"; + +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", + schema_limit: "the schema check, which hit its limit on dependent failures", +}; + +const PAYLOAD_VERSION = 1; + +export interface ValidateReport { + readonly file: string; + readonly format: "toml" | "json" | "jsonc"; + readonly remotes: ReadonlyArray; + readonly problems: ReadonlyArray; + readonly stoppedAt?: StopStage; +} + +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 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, + style: ReportStyle, +): ReadonlyArray { + const glyph = problem.severity === "error" ? style.red("✗") : style.yellow("⚠"); + const subject = safe( + problem.path === undefined + ? (problem.subject ?? problem.label ?? 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}`]; + const label = locationLabel(report, problem); + if (label !== undefined) { + out.push(` ${style.dim(label)}`); + } + 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 file = safe(report.file); + const out: Array = []; + + for (const problem of sortedByLocation(report.problems)) { + out.push(...renderProblem(report, problem, 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.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 path = problem.path === undefined ? undefined : displayPath(problem.path); + const key = + path === undefined + ? undefined + : problem.remote === undefined + ? path + : `remotes.${problem.remote}.${path}`; + const { location } = problem; + return { + code: problem.code, + severity: problem.severity, + ...(key === undefined ? {} : { key, 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, + 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..67047d8ec3 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.format.unit.test.ts @@ -0,0 +1,167 @@ +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"'].join("\n"); + +const problem = (fields: Partial): ConfigProblem => ({ + code: "invalid_type", + severity: "error", + reason: "bad", + ...fields, +}); + +const report = (problems: ReadonlyArray, extra: Partial = {}) => + ({ + file: "supabase/config.toml", + format: "toml", + remotes: [], + problems, + ...extra, + }) satisfies ValidateReport; + +const render = (problems: ReadonlyArray, extra: Partial = {}) => + validateReport(report(problems, extra), PLAIN_REPORT_STYLE); + +describe("validateReport", () => { + it("renders a located problem as three lines and prints no source text", () => { + 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", + " help: did you mean `port`?", + "", + "✗ 1 error in supabase/config.toml", + "", + ].join("\n"), + ); + for (const line of SOURCE.split("\n")) expect(text).not.toContain(line); + expect(text).not.toContain("hunter2"); + }); + + it("renders a problem without a location without a position line", () => { + const text = render([problem({ path: ["api", "port"], help: "fix it" })]); + + expect(text).toContain(" ✗ api.port bad\n help: fix it\n"); + }); + + it("labels remote problems and lists checked remotes", () => { + const text = render([problem({ path: ["db", "port"], remote: "staging" })], { + remotes: ["staging"], + }); + + expect(text).toContain("[remote: staging]"); + expect(text).toContain("Checked remotes: staging"); + }); + + 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("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( + [ + 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..9c11eccac4 --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.handler.ts @@ -0,0 +1,805 @@ +import { + CliConfigFileConflictError, + CliConfigParseError, + CliConfigSchemaVersionError, +} from "@supabase/config/effect"; +import { + type CliConfigLocation, + scanCliConfigDocument, + type CliConfigDocumentScan, + parseCliConfigDocumentFile, + type ParsedCliConfigDocument, +} from "@supabase/config/internal"; +import { Effect, FileSystem, Option, Path, 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 { cliRemoteProjectIdEnvName } from "../../../config/cli-config-keys.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 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 { + 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 { 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"; +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, + displayPath, + envReferences, + type EnvReference, + 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"]; + }, +): ConfigProblem => ({ severity: "error", 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 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(({ path, replacementPath, table, remote }) => { + const scope = remote === undefined ? [] : ["remotes", remote]; + const tomlTable = (segments: ReadonlyArray): string => + `[${[...scope, ...segments].map(tomlSegment).join(".")}]`; + return problem("removed_key", { + path, + reason: "removed", + help: + table && format === "toml" + ? `rename ${tomlTable(path)} to ${tomlTable(replacementPath)}` + : `use ${displayPath(replacementPath)} instead`, + ...(remote === undefined ? {} : { 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 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); + 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, + document: CliConfigDocumentScan, +): CliConfigLocation | undefined { + if (item.location !== undefined || item.path === undefined || item.external !== undefined) { + return item.location; + } + const segments = item.path; + const target = KEY_TARGET_CODES.has(item.code) ? "key" : "value"; + 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) => document.locate(path, target)); + return ( + attempts.find((found) => found?.exact === true) ?? attempts.find((found) => found !== undefined) + ); +} + +/** 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(".")); + const local = displayPath(split.path); + return problem("deprecated_key", { + severity: "warning", + path: split.path, + 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, + reason: isTable ? "unknown table" : "unknown key", + ...(suggestion === undefined ? {} : { help: `did you mean \`${suggestion}\`?` }), + ...(split.remote === undefined ? {} : { remote: split.remote }), + }); + }); +} + +const schemaProblems = ( + failures: ReadonlyArray, + pruned: ReadonlyArray> = [], +): ReadonlyArray => + failures.flatMap((failure) => { + 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) => { + const { path } = issue; + 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 }), + }); + }); + }); + +function invalidValueProblem(error: CliConfigValueError, file: string): ConfigProblem { + const fromEnv = error.tier === "shell" || error.tier === "projectEnv" || error.tier === "flag"; + const unquotedExpected = error.expected?.replace(/"([^",]*)"/g, (quoted, choice: string) => + choice === "" ? quoted : choice, + ); + const reason = + unquotedExpected !== undefined + ? `expected ${unquotedExpected}` + : error.issues !== undefined && error.issues.length > 0 + ? error.issues.join("; ") + : "invalid value"; + const path = error.segments ?? error.path.split("."); + 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`, + } + : {}), + }, + ); +} + +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.reads === undefined ? {} : { reads: issue.reads }), + ...(issue.path === "" ? {} : { path }), + reason: issue.reason, + ...(fromEnv + ? { + external: true as const, + help: `${describeOrigin(origin)} sets this value, not ${file}; fix or unset it there`, + } + : issue.help === undefined + ? {} + : { help: issue.help }), + }); +} + +function envLoadProblem(error: CliConfigLoadError): ConfigProblem { + const { file } = error; + if (file === undefined) { + return problem("env_file", { + label: "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: Pick, + file: string, +): ConfigProblem { + return problem("remote_project_id", { + path: ["project_id"], + remote: failure.remote, + reason: + failure.kind === "duplicate" + ? `same project_id as remotes.${failure.duplicateOf}` + : "not a project ref", + ...(failure.envName === undefined + ? { + help: + 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, + help: `${failure.envName} sets this project_id, not ${file}; fix or unset it there`, + }), + }); +} + +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 pathService = yield* Path.Path; + 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; readonly source?: string } = { readSource: true }, + ) { + 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), + remotes: [], + stoppedAt, + problems: problems.map((item) => ({ + ...item, + location: locateProblem(item, document), + })), + } 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)), + }), + ], + { readSource: true, source }, + ), + ); + } + 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.entries(rawRemotes) + .filter(([, value]) => isDocumentRecord(value)) + .map(([name]) => name) + : []; + + 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), + ...remotesShapeProblems(rawRemotes), + ]; + const declaration = parsed.schemaDeclaration; + if (declaration !== undefined && declaration.version === undefined) { + notices.push( + problem("unrecognized_schema", { + severity: "warning", + subject: file, + 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), + }), + ); + } + + 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 })], "removed_env"); + case "CliConfigLoadError": + return failed([envLoadProblem(error)], "env_file"); + default: + return yield* new ConfigValidateLoadError({ message: error.message }); + } + } + const resolved = result.success; + if (resolved.pruneLimitReached) { + return { + problems: withRemote(schemaProblems(resolved.schemaFailures, resolved.pruned), remote), + resolved, + stop: "schema_limit", + } satisfies PassResult; + } + const semantic = yield* collectLocalConfigIssues( + resolved.materialized.config, + workdir, + resolved.loaded.document, + resolved.loaded.path, + ); + const removedPaths = [ + ...resolved.pruned, + ...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, resolved.pruned), + ...resolved.invalid.map((error) => invalidValueProblem(error, file)), + ...withoutFollowOns( + semantic.map((issue) => + semanticProblem( + issue, + resolved.materialized.originAt(issue.path), + file, + describeOrigin, + ), + ), + removedPaths, + ), + ], + remote, + ), + resolved, + }; + return passed; + }); + + 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, + remotes: [], + stoppedAt: base.stop, + problems: [...notices, ...base.problems].map((item) => locatedProblem(item, document)), + } satisfies ValidateReport; + return yield* emit(report); + } + + const references = envReferences(parsed.rawDocument); + 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, + ), + ), + 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 checkedRemotes = remoteNames; + 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 => + isDocumentRecord(rawRemotes) && + path.some((_, index) => { + const value = getDocumentValue(rawRemotes[remote], path.slice(0, index + 1)); + return value !== undefined && (index === path.length - 1 || !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 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]; + 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); + 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.)`, + ...(remote === undefined ? {} : { remote }), + }); + const envWarnings = unresolved.flatMap((reference) => { + const warns = (context: string | undefined) => + !isOverridden(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)] : []; + } + if (warns(undefined)) return [envWarning(reference, undefined)]; + return checkedRemotes + .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) => locatedProblem(item, document)); + + const remoteStop = remotePasses.find((item) => item.stop !== undefined)?.stop; + return yield* emit({ + file, + format, + remotes: checkedRemotes, + ...(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 new file mode 100644 index 0000000000..8b5ff5d2da --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.integration.test.ts @@ -0,0 +1,1624 @@ +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 + help: did you mean \`port\`? + + ✗ api.max_rows expected a non-negative integer + supabase/config.toml:6:12 + + ✗ db.major_version unsupported Postgres version + supabase/config.toml:9:17 + help: use one of 14, 15, 17 + + ✗ auth.external.github.client_id required when auth.external.github.enabled = true + supabase/config.toml:11:2 + help: set client_id, or set enabled = false + + ✗ auth.external.github.secret required when auth.external.github.enabled = true + supabase/config.toml:11:2 + 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 + 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 + + ✗ 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 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 + help: did you mean \`port\`? + + ✗ api.max_rows expected a non-negative integer + supabase/config.jsonc:6:17 + + ✗ db.major_version unsupported Postgres version + supabase/config.jsonc:8:28 + help: use one of 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 project_id and still validates every remote block", () => + 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: ["a", "b", "c"], + complete: true, + }); + 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("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, "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; + 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("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( + "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.c]\nproject_id = "env(CPID)"\n[remotes.c.api]\nport = "zz"\n[remotes.c.db]\nmajor_version = 99\n', + ), + { format: "json" }, + ); + + 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" }, + ); + + 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)), + ); + + 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)), + ); + + 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( + 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("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("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("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'), { + 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("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( + 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("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.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( + 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( + 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( + 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("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`), + ); + + 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`, + ), + ); + 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 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"); + leaks(result); + expect(result.stdout).not.toContain(padded.slice(0, 8)); + }).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("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).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)), + ); + + 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`, + }, + ], + [ + "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`), + ], + [ + "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( + `#: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({ + "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..4c5fb24b6d --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.model.ts @@ -0,0 +1,201 @@ +import { ENV_CAPTURE_REGEX, type CliConfigLocation } from "@supabase/config/internal"; + +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 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; + /** 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; + readonly location?: CliConfigLocation; + /** The value comes from the environment, so the config file has no location for it. */ + readonly external?: 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. */ +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 }; +} + +/** 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?.join("\0") ?? ""}\0${problem.subject ?? ""}`; + +const VALUE_LEVEL_CODES: 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: ReadonlyArray) => 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 = VALUE_LEVEL_CODES.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 ?? [], 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 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. 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, + 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 || + 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) || + (problem.reads ?? []).some((read) => atOrBelow(reference.path, read)), + ); + for (const reference of owners) { + blamed.push({ + reference, + ...(problem.remote === undefined ? {} : { remote: problem.remote }), + }); + } + return owners.length === 0; + }); + return { kept, 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, trying each representation at every + * depth before moving outward, or `undefined` when no enclosing table has one. + */ +export function nearestEnabledGate( + configs: ReadonlyArray, + path: ReadonlyArray, +): boolean | undefined { + for (let depth = path.length - 1; depth > 0; depth--) { + 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 new file mode 100644 index 0000000000..50e00a90ab --- /dev/null +++ b/apps/cli/src/commands/config/validate/validate.model.unit.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; + +import { + type ConfigProblem, + dedupeProblems, + envReferences, + nearestEnabledGate, + splitRemotePath, + withoutFollowOns, + withoutUnresolvedEnvErrors, +} from "./validate.model.ts"; + +const problem = (fields: Partial & Pick): ConfigProblem => ({ + severity: "error", + reason: "bad", + ...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("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.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" }); + 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" }; + const referencesFor = + (...references: ReadonlyArray) => + (remote: string | undefined) => + references.filter((reference) => Reflect.get(reference, "remote") === remote); + + 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([{ 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" }); + + 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], referencesFor(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("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(); + }); +}); 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-failure-key.unit.test.ts b/apps/cli/src/config/cli-config-failure-key.unit.test.ts index 69c5390f0a..36e93f6200 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,13 @@ 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"], + table: true, + })), }); expect(cliConfigFailureKey(removedKeys(SECRET, "inbucket"))).toBe("inbucket"); diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index a8ac6fa9f0..a8742385db 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -160,6 +160,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 858af5d17c..8803324f36 100644 --- a/apps/cli/src/config/cli-config-key.ts +++ b/apps/cli/src/config/cli-config-key.ts @@ -399,6 +399,7 @@ export const describeCliConfigEnvLocation = ( interface CliConfigValueFailure { readonly path: string; + readonly segments: ReadonlyArray; readonly raw: string; readonly expected: string; readonly origin: Exclude; @@ -411,15 +412,19 @@ 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, + expected, + kind, message: `Invalid --${origin.flag}="${raw}" (sets ${path}): expected ${expected}.`, }); case "shell": @@ -430,9 +435,12 @@ const invalidCliConfigValue = ( : ""; return new CliConfigValueError({ path, + segments, tier: origin.tier, source, envName: origin.envName, + expected, + kind, message: `Invalid ${origin.envName}="${raw}"${file} (sets ${path}): expected ${expected}.`, }); } @@ -440,8 +448,11 @@ const invalidCliConfigValue = ( const where = describeCliConfigOrigin(origin, ctx); return new CliConfigValueError({ path, + segments, tier: "config", source, + expected, + kind, message: typeOnly ? `Invalid ${path}${where === "config" ? "" : ` in ${where}`}: expected ${expected}, got ${raw}.` : where === "config" @@ -473,6 +484,7 @@ export const pickCliConfigKey = => { if (!secret || typeof raw !== "string" || !isEncryptedSecret(raw)) return Result.succeed(raw); @@ -493,7 +505,13 @@ 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..1970694f1f 100644 --- a/apps/cli/src/config/cli-config-remote.ts +++ b/apps/cli/src/config/cli-config-remote.ts @@ -38,21 +38,42 @@ 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; + /** `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; +} + +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>, 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, + kind: "duplicate", + duplicateOf: prior, + message: `duplicate project_id for [remotes.${name}] and [remotes.${prior}]`, + ...overrideField(name, lookup), + }); + continue; } seen.set(projectId, name); } @@ -62,9 +83,14 @@ 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, + kind: "invalid", + message: `Invalid config for remotes.${name}.project_id. Must be like: abcdefghijklmnopqrst`, + ...overrideField(name, lookup), + }); } } - 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..0c4c2fa88f --- /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.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}` }; + } + 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..1fe79c985c --- /dev/null +++ b/apps/cli/src/config/cli-config-schema-issues.unit.test.ts @@ -0,0 +1,78 @@ +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("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 new file mode 100644 index 0000000000..649ab704d1 --- /dev/null +++ b/apps/cli/src/config/cli-config-unknown-keys.ts @@ -0,0 +1,110 @@ +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; + +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 => { + 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 custom auth providers. */ +export function reportableUnknownCliConfigKeys( + document: unknown, +): ReadonlyArray { + return findUnknownCliConfigKeys(document) + .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 { + 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..0664042527 --- /dev/null +++ b/apps/cli/src/config/cli-config-unknown-keys.unit.test.ts @@ -0,0 +1,69 @@ +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("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 } } }); + + 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-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.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts index b759671b59..79235218a8 100644 --- a/apps/cli/src/config/cli-config-values.integration.test.ts +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -2536,6 +2536,109 @@ 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("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"); + 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), + ); + }); }); describe("CliConfigValues config format telemetry", () => { diff --git a/apps/cli/src/config/cli-config-values.layer.ts b/apps/cli/src/config/cli-config-values.layer.ts index e064d27f71..0b256707ee 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, @@ -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 { @@ -55,14 +56,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 +79,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 +101,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 +196,29 @@ const withEnvOrigins = ): Record => { + const { remotes: _remotes, ...rest } = document; + return rest; +}; + +/** 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, @@ -286,20 +294,33 @@ 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).pipe( - Effect.tapError((error) => recordConfigFormat(failedConfigFilePath(error))), - ); + : yield* Cache.get( + target.validation ? validationParsedDocuments : parsedDocuments, + target.workdir, + ).pipe(Effect.tapError((error) => recordConfigFormat(failedConfigFilePath(error)))); yield* recordConfigFormat(parsed?.path); - for (const deprecatedPath of parsed?.deprecatedPaths ?? []) { + 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); @@ -307,13 +328,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)); } @@ -396,9 +413,15 @@ 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({ + message: firstRemoteFailure.message, + remote: firstRemoteFailure.remote, + }); } const merged = @@ -406,6 +429,7 @@ export const cliConfigValuesLayer = Layer.effect( ? null : yield* mergeParsedCliConfig(parsed, { selectRemote: (candidates) => + target.remoteName ?? selectCliConfigRemote(candidates, target.projectRef, lookupEnv), }); @@ -415,7 +439,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; @@ -549,6 +578,9 @@ export const cliConfigValuesLayer = Layer.effect( const issues = entryFailures.flatMap((failure) => failure.issues ?? []); return yield* new CliConfigValueError({ path: firstFailure.path, + ...(entryFailures.length === 1 && firstFailure.segments !== undefined + ? { segments: firstFailure.segments } + : {}), tier: "config", message: decodingFailedMessage(issues), issues, @@ -562,16 +594,56 @@ 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> = []; + 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, { + 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) || 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"), + ) + .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( @@ -580,17 +652,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, ); @@ -622,7 +688,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; @@ -641,6 +707,12 @@ export const cliConfigValuesLayer = Layer.effect( materialized, origins, invalid, + remoteFailures: target.validation ? remoteFailures : [], + schemaFailures, + get pruneLimitReached() { + return pruneLimitReached; + }, + pruned, familyNames, declares: (configPath) => configAt(configPath) !== undefined, withheldEnv, @@ -673,6 +745,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({ @@ -684,7 +757,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..d05f8d0623 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,14 @@ 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; + /** 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. */ 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..026feadec4 100644 --- a/apps/cli/src/config/cli-config.errors.ts +++ b/apps/cli/src/config/cli-config.errors.ts @@ -13,11 +13,17 @@ 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; 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; + /** How a codec rejected the value; absent for failures that are not a codec rejection. */ + readonly kind?: "type" | "enum" | "value"; /** 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-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 1e6f5939b1..77d58e83e6 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,11 @@ 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; + /** 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/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index b411c451a2..baa881520a 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 @@ -226,7 +226,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 371f33a7d7..163dd46d37 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -118,6 +118,10 @@ ConfigPushSslEnforcementUpdateStatusError ConfigPushStorageUpdateNetworkError ConfigPushStorageUpdateStatusError ConfigPushWorkdirError +ConfigValidateLoadError +ConfigValidateOutputFlagUnsupportedError +ConfigValidateProblemsError +ConfigValidateWorkdirError ConflictingFunctionDeployFlagsError ConflictingFunctionDownloadFlagsError ContainerCreateError diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 7f31681a60..1a12e374a9 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 { @@ -69,13 +70,11 @@ 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` 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: "" })); + } 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/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 8c56f33c73..aac069dfce 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), `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 new file mode 100644 index 0000000000..d432b362da --- /dev/null +++ b/packages/config/src/config-locate.ts @@ -0,0 +1,365 @@ +import { parseTree, type Node } from "jsonc-parser"; +import { + scanTomlDocument, + type TomlHeaderToken, + type TomlKeyToken, + type TomlScanError, + type TomlScanResult, +} from "./toml-scan.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)); + return { start, end: trimEnd(source, token.keysEnd) }; +} + +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 && (!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 === "]") { + 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); + +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, + 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; + } + 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; + 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 scan: TomlScanResult | TomlScanError; + readonly starts: ReadonlyArray; +} + +const scanToml = (source: string): ScannedToml => ({ + scan: scanTomlDocument(source), + starts: lineStarts(source), +}); + +function locateToml( + source: string, + path: ReadonlyArray, + target: "key" | "value", + { scan, starts }: ScannedToml, +): CliConfigLocation | undefined { + 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?.findLast((property) => property.children?.[0]?.value === segment); + const value = entry?.children?.[1]; + 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( + path: ReadonlyArray, + target: "key" | "value", + { root, starts }: ScannedJson, +): CliConfigLocation | undefined { + if (root === undefined) return undefined; + + 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; +} + +/** 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; +} + +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), + }; + } + const scanned = scanJson(source); + return { + 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 new file mode 100644 index 0000000000..27041a819e --- /dev/null +++ b/packages/config/src/config-locate.unit.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "vitest"; +import { scanCliConfigDocument } from "./config-locate.ts"; + +const locateCliConfigKey = ( + source: string, + format: "toml" | "json", + path: ReadonlyArray, + target: "key" | "value", +) => scanCliConfigDocument(source, format).locate(path, target); +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("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"), + ).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)", () => { + 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", + "{", + ' "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("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"'); + }); + + 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"); + }); +}); diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 20cbbfafa0..37831d75ad 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -546,6 +546,7 @@ describe("src/internal.ts export surface", () => { "remoteProjectIdEntries", "resolveCliConfigSubtree", "resolveCliConfigValue", + "scanCliConfigDocument", "unmappedSecretApiPaths", "updateJsonDocument", "withoutSafeIntegerBounds", diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index d59c297e27..48a19c2c06 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -22,7 +22,15 @@ 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; + /** True when the removed key is a whole table, such as `inbucket`. */ + readonly table: boolean; + readonly remote?: string; + }>; }> { override get message(): string { const file = this.path.split(/[\\/]/).slice(-2).join("/"); diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 9262254961..8dd858d426 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -59,4 +59,9 @@ 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 { + scanCliConfigDocument, + type CliConfigDocumentScan, + type CliConfigLocation, +} from "./config-locate.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 30d17fca89..97e8104987 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -16,6 +16,7 @@ import { import { updateJsonDocument } from "./config-edit.ts"; import type { ConfigFormat } from "./config-format.ts"; import { + type CliConfigRemovedKeyError, CliConfigFileConflictError, DuplicateRemoteProjectIdError, InvalidRemoteProjectIdError, @@ -27,7 +28,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, @@ -283,8 +284,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({ @@ -427,15 +429,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; }); /** @@ -511,6 +528,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* ( @@ -564,6 +583,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)); @@ -736,6 +756,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; } /** @@ -748,6 +770,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); @@ -756,8 +780,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, @@ -769,6 +793,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 004f2968ba..853a8c5bf2 100644 --- a/packages/config/src/removed-config-keys.ts +++ b/packages/config/src/removed-config-keys.ts @@ -5,11 +5,35 @@ 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 => + [...(remote === undefined ? [] : ["remotes", remote]), ...segments].map(tomlSegment).join("."); + return { + key: render(local), + replacement: render(replacementLocal), + path: local, + replacementPath: replacementLocal, + table, + ...(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; readonly scope: Record; @@ -18,7 +42,11 @@ 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"]; + } } const experimental = scope["experimental"]; @@ -26,12 +54,16 @@ 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`, - }); + removedKeys.push( + removed(remote, ["experimental", "orioledb_version"], ["db", "orioledb_version"], false), + ); + if (dropRemoved) { + const withoutVersion = { ...experimental }; + delete withoutVersion["orioledb_version"]; + stripped = { ...stripped, experimental: withoutVersion }; + } } } @@ -40,10 +72,26 @@ 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; + if (isObject(strippedAuth) && isObject(strippedExternal)) { + const withoutProvider = { ...strippedExternal }; + delete withoutProvider[provider]; + stripped = { + ...stripped, + auth: { ...strippedAuth, external: withoutProvider }, + }; + } + } } } } @@ -56,11 +104,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, undefined, dropRemoved); const removedKeys = [...root.removedKeys]; const remotes = document["remotes"]; if (!isObject(remotes)) { @@ -71,7 +122,7 @@ function detectRemovedConfigKeys(document: Record): { if (!isObject(remote)) { return [name, remote]; } - const scanned = scanScope(remote, `remotes.${name}.`); + const scanned = scanScope(remote, name, dropRemoved); removedKeys.push(...scanned.removedKeys); return [name, scanned.scope]; }), @@ -85,8 +136,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/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); 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); + }), + )); });