diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 693e2e3328..edef0c6781 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -12,13 +12,14 @@ not a compatibility promise. These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| `config diff` | [`../src/legacy/commands/config/diff/diff.command.ts`](../src/legacy/commands/config/diff/diff.command.ts) | Read-only drift report between `supabase/config.toml` and `GET /v2/projects/{ref}/config` (CLI-2156). TS-only: the old Go CLI had no config diff. `--target` accepts a branch name/UUID/ref; `--exit-code` exits 1 on drift. Rejects the Go-compat `-o/--output` flag outright — machine output is `--output-format json\|stream-json` only (no Go parity contract for net-new commands, per the CLI-2156 discussion). Comparison core lives in `@supabase/config` (ADR 0019). | ## Flag divergences from the Go reference diff --git a/apps/cli/src/legacy/commands/branches/branches.resolver.ts b/apps/cli/src/legacy/commands/branches/branches.resolver.ts index ff666f9d47..6f66f5c297 100644 --- a/apps/cli/src/legacy/commands/branches/branches.resolver.ts +++ b/apps/cli/src/legacy/commands/branches/branches.resolver.ts @@ -1,7 +1,5 @@ -import { Effect } from "effect"; - -import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { legacyResolveBranchProjectRef as legacyResolveBranchProjectRefShared } from "../../shared/legacy-branch-ref.resolver.ts"; import { LegacyBranchesFindNetworkError, LegacyBranchesFindUnexpectedStatusError, @@ -9,21 +7,6 @@ import { LegacyBranchesGetUnexpectedStatusError, } from "./branches.errors.ts"; -/** - * Project ref pattern shared by every Management-API endpoint that accepts a - * 20-lowercase-letter project reference. Re-export so siblings (e.g. - * `get.handler.ts`) can classify branch-id inputs without re-declaring it. - */ -export const LEGACY_BRANCH_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -/** - * Permissive UUID pattern (any 8-4-4-4-12 hex sequence) — accepts any RFC 4122 - * variant including v6/v7 and version 0, matching the established liberal - * acceptance rather than the v1–v5 + variant-1 subset. - */ -export const LEGACY_BRANCH_UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const mapFindError = mapLegacyHttpError({ networkError: LegacyBranchesFindNetworkError, statusError: LegacyBranchesFindUnexpectedStatusError, @@ -39,38 +22,10 @@ const mapGetError = mapLegacyHttpError({ }); /** - * Resolves an arbitrary branch identifier to its project ref: - * - * 1. If the input matches `^[a-z]{20}$`, it's already a project ref — return as-is. - * 2. Else if the input is a UUID, call `V1GetABranchConfig` (`GET /v1/branches/{id}`) - * and return `JSON200.ref`. - * 3. Otherwise treat as a branch name under the linked project ref: call - * `V1GetABranch` (`GET /v1/projects/{ref}/branches/{name}`) and return - * `JSON200.project_ref`. - * - * The persistent `--project-ref` is required for path 3 and is passed in by - * the caller (which has already run `LegacyProjectRefResolver` so the linked - * project cache write does not re-fire here). + * The branches family's binding of the shared branch-ref resolver + * (`legacy/shared/legacy-branch-ref.resolver.ts`) to this family's error + * classes. See the shared module for resolution semantics. */ -export const legacyResolveBranchProjectRef = Effect.fnUntraced(function* ( - input: string, - projectRef: string, -) { - if (LEGACY_BRANCH_PROJECT_REF_PATTERN.test(input)) { - return input; - } - - const api = yield* LegacyPlatformApi; - - if (LEGACY_BRANCH_UUID_PATTERN.test(input)) { - const detail = yield* api.v1 - .getABranchConfig({ branch_id_or_ref: input }) - .pipe(Effect.catch(mapGetError)); - return detail.ref; - } - - const branch = yield* api.v1 - .getABranch({ ref: projectRef, name: input }) - .pipe(Effect.catch(mapFindError)); - return branch.project_ref; -}); +export function legacyResolveBranchProjectRef(input: string, projectRef: string) { + return legacyResolveBranchProjectRefShared(input, projectRef, { mapGetError, mapFindError }); +} diff --git a/apps/cli/src/legacy/commands/branches/get/get.handler.ts b/apps/cli/src/legacy/commands/branches/get/get.handler.ts index bc211c6a61..f90b3f40f1 100644 --- a/apps/cli/src/legacy/commands/branches/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/branches/get/get.handler.ts @@ -39,7 +39,7 @@ import { legacyPromptBranchId } from "../branches.prompt.ts"; import { LEGACY_BRANCH_PROJECT_REF_PATTERN, LEGACY_BRANCH_UUID_PATTERN, -} from "../branches.resolver.ts"; +} from "../../../shared/legacy-branch-ref.resolver.ts"; import type { LegacyBranchesGetFlags } from "./get.command.ts"; type BranchDetail = typeof V1GetABranchConfigOutput.Type; diff --git a/apps/cli/src/legacy/commands/config/config.command.ts b/apps/cli/src/legacy/commands/config/config.command.ts index efec9afa22..0c13ba938d 100644 --- a/apps/cli/src/legacy/commands/config/config.command.ts +++ b/apps/cli/src/legacy/commands/config/config.command.ts @@ -1,8 +1,9 @@ import { Command } from "effect/unstable/cli"; +import { legacyConfigDiffCommand } from "./diff/diff.command.ts"; import { legacyConfigPushCommand } from "./push/push.command.ts"; export const legacyConfigCommand = Command.make("config").pipe( Command.withDescription("Manage Supabase project configurations."), Command.withShortDescription("Manage project configurations"), - Command.withSubcommands([legacyConfigPushCommand]), + Command.withSubcommands([legacyConfigDiffCommand, legacyConfigPushCommand]), ); diff --git a/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md new file mode 100644 index 0000000000..ad04d67554 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md @@ -0,0 +1,107 @@ +# `supabase config diff` + +Read-only comparison between the local `supabase/config.toml` and the effective +configuration the Management API reports for a target project or branch. +Classifies every remotely-managed property as `update` / `remote_only` / +`local_only` (unmanaged local-only properties are never reported). **Never +writes `config.toml` or any remote configuration.** + +TS-only command — no Go CLI equivalent (see `docs/go-cli-divergences.md`). + +## Files Read + +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always, before any network call (missing file or parse error aborts, exit 1) | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref for `--target` | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, for the telemetry cache write below | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | + +## Files Written + +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | + +**No writes to `supabase/config.toml` or `supabase/config.json`** — covered by +an integration test asserting mtime and contents are unchanged after a run +that finds differences. + +## API Routes + +All Bearer-authenticated, all read-only. + +| # | Purpose | Method | Path | Success | Notes | +| --- | ---------------------------------- | ------ | ------------------------------------ | ------- | ---------------------------------------------------------------------- | +| 0a | branch by UUID (`--target `) | GET | `/v1/branches/{branch_id}` | 200 | only when `--target` is a UUID | +| 0b | branch by name (`--target `) | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--target` is not a ref/UUID; 404 → "branch not found" error | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution) | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------ | +| `0` | success — including when differences are found, unless `--exit-code` is passed | +| `1` | `--exit-code` passed and at least one difference found | +| `1` | the Go-compat `-o/--output` global flag passed (any value — unsupported here) | +| `1` | missing or malformed `supabase/config.toml` | +| `1` | `--target` and `--project-ref` passed together | +| `1` | unknown branch (`--target` 404) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | remote config read failure (network or unexpected status) | + +## Output + +Diagnostics on **stderr**: `Comparing against …` (resolved target + local +scope, i.e. `[remotes.]` or `base config`) before the fetch, then +`Comparison scope: ` listing the blocks the response carried (missing +blocks are called out). The payload is on **stdout**. + +### `--output-format text` + +One block per difference (` [update|remote only|local only]` with +`local:`/`remote:` lines; unset renders `(unset)` / `(not returned)`, +env-resolved values append `(from env VAR)`), then a summary count line — +`No config differences found.` when clean — and a +`Note: N credential value(s) not compared (masked by the API): …` line when +the file sets masked secrets. + +### `--output-format json` / `stream-json` + +`output.success(message, payload)` with the payload containing +`schema_version`, `target` (`project_ref`, optional `branch`, `local_scope`), +`scope`, `changes[]` (`path`, `class`, `local`, `remote`, optional +`env_variable`; unset sides are `null`), `masked[]`, and `counts` +(per class + `total`). + +### `-o/--output` (Go-compat global flag) + +**Not supported.** Any `-o` value — the machine formats and `pretty` alike — +fails fast (before target resolution or any network call) with +`the -o/--output flag is not supported by config diff; use --output-format +json|stream-json instead.` This is a net-new TS command with no Go parity +contract (CLI-2156 ticket discussion). + +## Notes + +- Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. +- **Local operand per target (ADR 0018/0019):** when the resolved target ref matches a + `[remotes.]` block's `project_id`, the local side is that branch's merged + effective config; otherwise the base config. The echoed scope line always says which. +- **Masked credentials:** secret-valued managed properties (the platform returns an HMAC, + never plaintext) are treated as "present, unknown" — never reported as differences and + never counted for `--exit-code`; they are surfaced via the masked note / `masked[]`. +- **Partial responses:** a managed property the response does not carry is `local_only` + when the file declares it and silent otherwise; a missing block is called out on the + scope line rather than treated as an error. diff --git a/apps/cli/src/legacy/commands/config/diff/diff.command.ts b/apps/cli/src/legacy/commands/config/diff/diff.command.ts new file mode 100644 index 0000000000..c8fba014dc --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.command.ts @@ -0,0 +1,49 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyConfigDiff } from "./diff.handler.ts"; + +const config = { + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), + target: Flag.string("target").pipe( + Flag.withDescription( + "Branch name, branch ID, or project ref to compare against. Mutually exclusive with --project-ref.", + ), + Flag.optional, + ), + exitCode: Flag.boolean("exit-code").pipe( + Flag.withDescription("Exit with status 1 when any difference is found."), + ), +} as const; + +export type LegacyConfigDiffFlags = CliCommand.Command.Config.Infer; + +export const legacyConfigDiffCommand = Command.make("diff", config).pipe( + Command.withDescription( + "Shows configuration differences between supabase/config.toml and a remote project or branch. Read-only: never modifies local or remote configuration.", + ), + Command.withShortDescription("Diff local config against a remote project"), + Command.withExamples([ + { + command: "supabase config diff", + description: "Diff against the linked project", + }, + { + command: "supabase config diff --target staging --exit-code", + description: "Diff against the 'staging' branch, exiting 1 on drift", + }, + ]), + Command.withHandler((flags) => + legacyConfigDiff(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["config", "diff"])), +); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.errors.ts b/apps/cli/src/legacy/commands/config/diff/diff.errors.ts new file mode 100644 index 0000000000..902456af19 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.errors.ts @@ -0,0 +1,94 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; + +interface NetworkErrorArgs { + readonly message: string; + readonly decode?: boolean; +} + +interface StatusErrorArgs { + readonly status: number; + readonly body: string; + readonly message: string; +} + +/** Local config file missing or unparseable. Aborts before any network call. */ +export class LegacyConfigDiffLoadConfigError extends Data.TaggedError( + "LegacyConfigDiffLoadConfigError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** + * The Go-compat global `-o/--output` flag was passed. `config diff` is a + * net-new TS command with no Go parity contract, so machine output goes + * through `--output-format` only (per Colum on CLI-2156). + */ +export class LegacyConfigDiffOutputFlagUnsupportedError extends Data.TaggedError( + "LegacyConfigDiffOutputFlagUnsupportedError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** `--target` and `--project-ref` passed together. */ +export class LegacyConfigDiffFlagConflictError extends Data.TaggedError( + "LegacyConfigDiffFlagConflictError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** `--target` named a branch the parent project does not have. */ +export class LegacyConfigDiffBranchNotFoundError extends Data.TaggedError( + "LegacyConfigDiffBranchNotFoundError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class LegacyConfigDiffBranchResolveNetworkError extends Data.TaggedError( + "LegacyConfigDiffBranchResolveNetworkError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigDiffBranchResolveStatusError extends Data.TaggedError( + "LegacyConfigDiffBranchResolveStatusError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} + +export class LegacyConfigDiffReadNetworkError extends Data.TaggedError( + "LegacyConfigDiffReadNetworkError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigDiffReadStatusError extends Data.TaggedError( + "LegacyConfigDiffReadStatusError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/config/diff/diff.format.ts b/apps/cli/src/legacy/commands/config/diff/diff.format.ts new file mode 100644 index 0000000000..d0174d9c46 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.format.ts @@ -0,0 +1,174 @@ +import type { + ConfigChange, + ConfigChangeSet, + ProjectConfigValueOrigin, + RemoteConfigBlock, + RemoteProjectConfig, +} from "@supabase/config"; +import { REMOTE_CONFIG_BLOCKS } from "@supabase/config"; + +/** + * Pure formatters, payload builders, and input adapters for `config diff` — + * no Effect, no services, unit-testable in isolation. + */ + +function isRemoteBlockRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asRemoteBlock(value: unknown): Readonly> | undefined { + return isRemoteBlockRecord(value) ? value : undefined; +} + +/** + * Adapts the generated client's decoded `data.attributes` to the loose + * per-block records the comparison core reads. Non-record values (which the + * generated schema should never produce, but the core must not trust) read as + * "block not returned". + */ +export function legacyConfigDiffRemoteBlocks(attributes: { + readonly api: unknown; + readonly auth: unknown; + readonly database: unknown; + readonly pooler: unknown; + readonly realtime: unknown; + readonly storage: unknown; +}): RemoteProjectConfig { + return { + api: asRemoteBlock(attributes.api), + auth: asRemoteBlock(attributes.auth), + database: asRemoteBlock(attributes.database), + pooler: asRemoteBlock(attributes.pooler), + realtime: asRemoteBlock(attributes.realtime), + storage: asRemoteBlock(attributes.storage), + }; +} + +/** + * Extracts `dotted path → env var name` for every `env()`-resolved leaf, so a + * change on such a property can name the variable involved. + */ +export function legacyConfigDiffEnvReferences( + valueOrigins: ReadonlyArray | undefined, +): ReadonlyMap { + const references = new Map(); + for (const origin of valueOrigins ?? []) { + if (origin.source === "environment" && origin.envVariable !== undefined) { + references.set(origin.path.join("."), origin.envVariable); + } + } + return references; +} + +export interface LegacyConfigDiffContext { + /** The resolved comparison target's project ref. */ + readonly projectRef: string; + /** The `--target` value, when a branch was named. */ + readonly branch: string | undefined; + /** Matched `[remotes.]` block, when the local operand was merged. */ + readonly appliedRemote: string | undefined; + /** The local file's `$schema` ref (or the current schema URL). */ + readonly schemaVersion: string; +} + +const CLASS_LABELS: Record = { + update: "update", + remote_only: "remote only", + local_only: "local only", +}; + +function renderValue(value: unknown, absent: string): string { + if (value === undefined) { + return absent; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + +function localScope(context: LegacyConfigDiffContext): string { + return context.appliedRemote === undefined ? "base config" : `[remotes.${context.appliedRemote}]`; +} + +/** The target-echo line, printed to stderr before any comparison output. */ +export function legacyConfigDiffComparisonLine(context: LegacyConfigDiffContext): string { + const target = + context.branch === undefined + ? `project ${context.projectRef}` + : `'${context.branch}' (branch ${context.projectRef})`; + return `Comparing against ${target} using ${localScope(context)}\n`; +} + +/** The scope-echo line, printed to stderr once the response arrived. */ +export function legacyConfigDiffScopeLine(scope: ReadonlyArray): string { + const present = scope.length === 0 ? "(none)" : scope.join(", "); + const missing = REMOTE_CONFIG_BLOCKS.filter((block) => !scope.includes(block)); + const suffix = missing.length === 0 ? "" : ` (not returned: ${missing.join(", ")})`; + return `Comparison scope: ${present}${suffix}\n`; +} + +function maskedNote(masked: ReadonlyArray): string { + return `Note: ${masked.length} credential value(s) not compared (masked by the API): ${masked.join(", ")}\n`; +} + +/** Human-readable diff body for text mode (stdout). */ +export function legacyRenderConfigDiffText(changeSet: ConfigChangeSet): string { + const lines: Array = []; + for (const change of changeSet.changes) { + lines.push(`${change.path} [${CLASS_LABELS[change.class]}]`); + const local = renderValue(change.local, "(unset)"); + const env = change.envVariable === undefined ? "" : ` (from env ${change.envVariable})`; + lines.push(` local: ${local}${env}`); + lines.push(` remote: ${renderValue(change.remote, "(not returned)")}`); + lines.push(""); + } + + const { update, remote_only, local_only } = changeSet.counts; + const total = update + remote_only + local_only; + if (total === 0) { + lines.push("No config differences found."); + } else { + lines.push( + `${total} difference(s) found (${update} update, ${remote_only} remote-only, ${local_only} local-only).`, + ); + } + if (changeSet.masked.length > 0) { + lines.push(maskedNote(changeSet.masked).trimEnd()); + } + return `${lines.join("\n")}\n`; +} + +/** + * The structured result for `--output-format json|stream-json`. Unset sides + * are explicit `null`s, distinguishable from empty values. + */ +export function legacyConfigDiffPayload( + changeSet: ConfigChangeSet, + context: LegacyConfigDiffContext, +): Record { + const valueEntry = (key: string, value: unknown): Record => ({ + [key]: value === undefined ? null : value, + }); + + const { update, remote_only, local_only } = changeSet.counts; + return { + schema_version: context.schemaVersion, + target: { + project_ref: context.projectRef, + ...valueEntry("branch", context.branch), + local_scope: + context.appliedRemote === undefined ? "base" : `remotes.${context.appliedRemote}`, + }, + scope: changeSet.scope, + changes: changeSet.changes.map((change) => ({ + path: change.path, + class: change.class, + ...valueEntry("local", change.local), + ...valueEntry("remote", change.remote), + ...(change.envVariable === undefined ? {} : { env_variable: change.envVariable }), + })), + masked: changeSet.masked, + counts: { update, remote_only, local_only, total: update + remote_only + local_only }, + }; +} diff --git a/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts new file mode 100644 index 0000000000..0d93a1ee3f --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyConfigDiffEnvReferences, + legacyConfigDiffRemoteBlocks, + legacyConfigDiffScopeLine, +} from "./diff.format.ts"; + +describe("legacyConfigDiffRemoteBlocks", () => { + test("keeps record blocks and drops non-record ones", () => { + const blocks = legacyConfigDiffRemoteBlocks({ + api: { max_rows: 5 }, + auth: {}, + database: null, + pooler: undefined, + realtime: [1], + storage: "nope", + }); + expect(blocks.api).toEqual({ max_rows: 5 }); + expect(blocks.auth).toEqual({}); + expect(blocks.database).toBeUndefined(); + expect(blocks.pooler).toBeUndefined(); + expect(blocks.realtime).toBeUndefined(); + expect(blocks.storage).toBeUndefined(); + }); +}); + +describe("legacyConfigDiffEnvReferences", () => { + test("collects env-var names for environment origins only", () => { + const references = legacyConfigDiffEnvReferences([ + { path: ["api", "max_rows"], source: "environment", envVariable: "PGRST_MAX_ROWS" }, + { path: ["auth", "site_url"], source: "local" }, + // An environment origin with no recorded name (pre-existing data) is skipped. + { path: ["db", "port"], source: "environment" }, + ]); + expect(references.get("api.max_rows")).toBe("PGRST_MAX_ROWS"); + expect(references.size).toBe(1); + }); + + test("no value origins means no references", () => { + expect(legacyConfigDiffEnvReferences(undefined).size).toBe(0); + }); +}); + +describe("legacyConfigDiffScopeLine", () => { + test("calls out blocks the response did not return", () => { + expect(legacyConfigDiffScopeLine(["api", "auth"])).toBe( + "Comparison scope: api, auth (not returned: database, pooler, realtime, storage)\n", + ); + }); + + test("an empty response scope renders (none)", () => { + expect(legacyConfigDiffScopeLine([])).toBe( + "Comparison scope: (none) (not returned: api, auth, database, pooler, realtime, storage)\n", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.handler.ts b/apps/cli/src/legacy/commands/config/diff/diff.handler.ts new file mode 100644 index 0000000000..7e80784077 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.handler.ts @@ -0,0 +1,195 @@ +import { diffProjectConfig, loadProjectConfig, PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; +import { Effect, Option } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + LEGACY_BRANCH_PROJECT_REF_PATTERN, + legacyResolveBranchProjectRef, +} from "../../../shared/legacy-branch-ref.resolver.ts"; +import { + legacySanitizeInlineName, + mapLegacyHttpError, +} from "../../../shared/legacy-http-errors.ts"; +import { + legacyConfigDiffComparisonLine, + legacyConfigDiffEnvReferences, + legacyConfigDiffPayload, + legacyConfigDiffRemoteBlocks, + legacyConfigDiffScopeLine, + legacyRenderConfigDiffText, + type LegacyConfigDiffContext, +} from "./diff.format.ts"; +import { + LegacyConfigDiffBranchNotFoundError, + LegacyConfigDiffBranchResolveNetworkError, + LegacyConfigDiffBranchResolveStatusError, + LegacyConfigDiffFlagConflictError, + LegacyConfigDiffLoadConfigError, + LegacyConfigDiffOutputFlagUnsupportedError, + LegacyConfigDiffReadNetworkError, + LegacyConfigDiffReadStatusError, +} from "./diff.errors.ts"; +import type { LegacyConfigDiffFlags } from "./diff.command.ts"; + +const readStatusMessage = (status: number, body: string) => `unexpected status ${status}: ${body}`; + +const mapBranchResolveError = mapLegacyHttpError({ + networkError: LegacyConfigDiffBranchResolveNetworkError, + statusError: LegacyConfigDiffBranchResolveStatusError, + networkMessage: (cause) => `failed to resolve branch: ${cause}`, + statusMessage: readStatusMessage, +}); + +export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( + flags: LegacyConfigDiffFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const runtimeInfo = yield* RuntimeInfo; + const processControl = yield* ProcessControl; + const goOutputFlag = yield* LegacyOutputFlag; + + // Net-new TS command with no Go parity contract: the Go-compat `-o/--output` + // flag is rejected outright (every value, `pretty` included) rather than + // honored — machine output goes through `--output-format` only (CLI-2156, + // per Colum). Checked first so no target resolution or network call runs. + if (Option.isSome(goOutputFlag)) { + return yield* new LegacyConfigDiffOutputFlagUnsupportedError({ + message: + "the -o/--output flag is not supported by config diff; use --output-format json|stream-json instead.", + }); + } + + if (Option.isSome(flags.target) && Option.isSome(flags.projectRef)) { + return yield* new LegacyConfigDiffFlagConflictError({ + message: "--target and --project-ref are mutually exclusive; pass at most one.", + }); + } + + // Resolve the comparison target to a project ref. `--target` accepts a + // branch name, a branch UUID, or a raw project ref (same acceptance as + // `link`); a ref-shaped value skips the parent-project resolution entirely + // so it works in an unlinked directory. + let ref: string; + let branch: string | undefined; + if (Option.isSome(flags.target) && !LEGACY_BRANCH_PROJECT_REF_PATTERN.test(flags.target.value)) { + const target = flags.target.value; + branch = target; + const parentRef = yield* resolver.resolve(Option.none()); + ref = yield* legacyResolveBranchProjectRef(target, parentRef, { + mapGetError: mapBranchResolveError, + mapFindError: mapBranchResolveError, + }).pipe( + Effect.catchTag( + "LegacyConfigDiffBranchResolveStatusError", + ( + cause, + ): Effect.Effect< + never, + LegacyConfigDiffBranchNotFoundError | LegacyConfigDiffBranchResolveStatusError + > => + cause.status === 404 + ? Effect.fail( + new LegacyConfigDiffBranchNotFoundError({ + message: `Branch "${legacySanitizeInlineName(target)}" not found. Run \`supabase branches list\` to see available branches.`, + }), + ) + : Effect.fail(cause), + ), + ); + } else if (Option.isSome(flags.target)) { + ref = flags.target.value; + } else { + ref = yield* resolver.resolve(flags.projectRef); + } + + yield* Effect.gen(function* () { + // 1. Load the local config, merging a matching `[remotes.*]` block over + // the base document when the target ref names a declared branch (ADR + // 0018). Never writes — this command is read-only by contract. + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyConfigDiffLoadConfigError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + Effect.catchTag( + "DuplicateRemoteProjectIdError", + (cause) => new LegacyConfigDiffLoadConfigError({ message: cause.message }), + ), + ); + if (loaded === null) { + return yield* new LegacyConfigDiffLoadConfigError({ + message: + "failed to read supabase/config.toml: file not found. Run `supabase init` to create one.", + }); + } + + const context: LegacyConfigDiffContext = { + projectRef: ref, + branch, + appliedRemote: loaded.appliedRemote, + schemaVersion: loaded.schemaRef ?? PROJECT_CONFIG_SCHEMA_URL, + }; + yield* output.raw(legacyConfigDiffComparisonLine(context), "stderr"); + + // 2. Fetch the effective remote config (single read-only call). + const fetching = + output.format === "text" ? yield* output.task("Fetching remote config...") : undefined; + const response = yield* api.v2.getProjectConfig({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch( + mapLegacyHttpError({ + networkError: LegacyConfigDiffReadNetworkError, + statusError: LegacyConfigDiffReadStatusError, + networkMessage: (cause) => `failed to read project config: ${cause}`, + statusMessage: readStatusMessage, + }), + ), + ); + yield* fetching?.clear() ?? Effect.void; + + // 3. Classify. `declared` is the raw merged document (key presence); + // `local` is the decoded effective config; env-resolved leaves carry the + // resolving variable's name for the output. + const changeSet = diffProjectConfig({ + local: loaded.config, + declared: loaded.document, + remote: legacyConfigDiffRemoteBlocks(response.data.attributes), + envReferences: legacyConfigDiffEnvReferences(loaded.valueOrigins), + }); + + yield* output.raw(legacyConfigDiffScopeLine(changeSet.scope), "stderr"); + + // 4. Emit: `--output-format json|stream-json` structured payload, or text. + if (output.format !== "text") { + const total = changeSet.changes.length; + const message = + total === 0 ? "No config differences found." : `${total} config difference(s) found.`; + yield* output.success(message, legacyConfigDiffPayload(changeSet, context)); + } else { + yield* output.raw(legacyRenderConfigDiffText(changeSet)); + } + + // 5. `--exit-code`: differences flip the exit status after the payload is + // out, without an error envelope corrupting machine output. + if (flags.exitCode && changeSet.changes.length > 0) { + yield* processControl.setExitCode(1); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts new file mode 100644 index 0000000000..ebb2be012f --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts @@ -0,0 +1,614 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + legacyJsonResponse, + legacyTransportFailure, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyConfigDiff } from "./diff.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-config-diff-int-"); + +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const BRANCH_REF = "cccccccccccccccccccc"; + +function writeConfig(toml: string): string { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "config.toml"); + writeFileSync(path, toml); + return path; +} + +function writeProjectEnv(dotenv: string): void { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), dotenv); +} + +/** + * Schema-valid v2 project-config body whose managed values all sit at the + * local schema defaults, so an empty config.toml diffs clean against it. + */ +function v2Response( + opts: { + readonly ref?: string; + readonly attributes?: (attributes: Record) => Record; + } = {}, +) { + const attributes: Record = { + database: { + ssl_enforced: false, + network_restrictions: { + entitlement: "allowed", + status: "applied", + allowed_cidrs: [ + { address: "0.0.0.0/0", type: "v4" }, + { address: "::/0", type: "v6" }, + ], + }, + postgres_settings: {}, + }, + pooler: { + pool_mode: "transaction", + ignore_startup_parameters: "", + server_idle_timeout: 0, + server_lifetime: 0, + query_wait_timeout: 0, + reserve_pool_size: 0, + default_pool_size: 20, + max_client_conn: 100, + }, + auth: {}, + api: { + db_schema: "public,graphql_public", + db_extra_search_path: "public,extensions", + max_rows: 1000, + db_pool_acquisition_timeout: 10, + db_pool: null, + }, + realtime: { + private_only: false, + max_concurrent_users: 200, + max_events_per_second: 100, + max_bytes_per_second: 100000, + max_channels_per_client: 100, + max_joins_per_second: 100, + max_presence_events_per_second: 100, + max_payload_size_in_kb: 100, + presence_enabled: true, + suspend: false, + connection_pool: 10, + postgres_changes_pool: null, + }, + storage: { + file_size_limit: 52428800, + features: { + image_transformation: { enabled: false }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: false }, + iceberg_catalog: { enabled: false, max_namespaces: 5, max_tables: 10, max_catalogs: 2 }, + vector_buckets: { enabled: true, max_buckets: 10, max_indexes: 5 }, + }, + capabilities: { list_v2: true, iceberg_catalog: false }, + upstream_target: "main", + migration_version: "20240701", + database_pool_mode: "transaction", + }, + }; + return { + data: { + type: "project_config", + id: opts.ref ?? LEGACY_VALID_REF, + attributes: opts.attributes === undefined ? attributes : opts.attributes(attributes), + }, + }; +} + +/** V1GetABranch body for the `--target ` lookup. */ +const BRANCH_BY_NAME = { + id: BRANCH_UUID, + name: "staging", + project_ref: BRANCH_REF, + parent_project_ref: LEGACY_VALID_REF, + is_default: false, + persistent: true, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}; + +/** V1GetABranchConfig body for the `--target ` lookup. */ +const BRANCH_CONFIG = { + ref: BRANCH_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "h", + db_port: 5432, +}; + +interface SetupOpts { + readonly toml?: string; + readonly dotenv?: string; + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly v2?: { status: number; body: unknown } | "fail"; + readonly branchByName?: { status: number; body: unknown }; + readonly branchByUuid?: { status: number; body: unknown }; +} + +function setup(opts: SetupOpts = {}) { + if (opts.toml !== undefined) { + writeConfig(opts.toml); + } + if (opts.dotenv !== undefined) { + writeProjectEnv(opts.dotenv); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + const url = request.url; + if (url.includes("/v2/projects/")) { + if (opts.v2 === "fail") { + return Effect.fail(legacyTransportFailure(request)); + } + const v2 = opts.v2 ?? { status: 200, body: v2Response() }; + return Effect.succeed(legacyJsonResponse(request, v2.status, v2.body)); + } + if (url.includes("/v1/branches/")) { + const b = opts.branchByUuid ?? { status: 200, body: BRANCH_CONFIG }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + if (url.includes("/branches/")) { + const b = opts.branchByName ?? { status: 200, body: BRANCH_BY_NAME }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + return Effect.succeed(legacyJsonResponse(request, 200, {})); + }, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + processControl, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + ); + return { layer, out, api, telemetry, linkedProjectCache, processControl }; +} + +const noFlags = { + projectRef: Option.none(), + target: Option.none(), + exitCode: false, +}; + +describe("legacy config diff integration", () => { + it.live("reports drift against the linked project without touching the config file", () => { + const { layer, out, processControl, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + const configPath = join(tempRoot.current, "supabase", "config.toml"); + const before = { + mtimeMs: statSync(configPath).mtimeMs, + contents: readFileSync(configPath, "utf8"), + }; + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + + // Never writes: mtime and contents unchanged after a run with differences. + expect(statSync(configPath).mtimeMs).toBe(before.mtimeMs); + expect(readFileSync(configPath, "utf8")).toBe(before.contents); + + expect(out.stderrText).toContain( + `Comparing against project ${LEGACY_VALID_REF} using base config`, + ); + expect(out.stderrText).toContain( + "Comparison scope: api, auth, database, pooler, realtime, storage", + ); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("local: 500"); + expect(out.stdoutText).toContain("remote: 1000"); + expect(out.stdoutText).toContain( + "1 difference(s) found (1 update, 0 remote-only, 0 local-only).", + ); + // Differences without --exit-code leave the exit status alone. + expect(processControl.exitCode).toBeUndefined(); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("a clean config produces the success message and exit 0 even with --exit-code", () => { + const { layer, out, processControl } = setup({ toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--exit-code sets exit 1 when differences are found", () => { + const { layer, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(processControl.exitCode).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("declared properties the response does not carry are local_only", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth]\nsite_url = "https://local.example.com"\n', + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("auth.site_url [local only]"); + expect(out.stdoutText).toContain('local: "https://local.example.com"'); + expect(out.stdoutText).toContain("remote: (not returned)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("env()-resolved values compare resolved and name the variable on drift", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = "env(PGRST_MAX_ROWS)"\n', + dotenv: "PGRST_MAX_ROWS=500\n", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("local: 500 (from env PGRST_MAX_ROWS)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("declared secrets are masked, not compared, and never count for --exit-code", () => { + const { layer, out, processControl } = setup({ + toml: [ + 'project_id = "test"', + "[auth.external.github]", + "enabled = true", + 'client_id = "id"', + 'secret = "env(GITHUB_SECRET)"', + "", + ].join("\n"), + dotenv: "GITHUB_SECRET=shh\n", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { external_github_enabled: true, external_github_client_id: "id" }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(out.stdoutText).toContain( + "Note: 1 credential value(s) not compared (masked by the API): auth.external.github.secret", + ); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("a matching [remotes.*] block becomes the local operand", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[api]", + "max_rows = 500", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.staging.api]", + "max_rows = 1000", + "", + ].join("\n"), + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain( + `Comparing against project ${LEGACY_VALID_REF} using [remotes.staging]`, + ); + // The merged branch operand (max_rows = 1000) matches the remote, so the + // base config's 500 must NOT surface as drift. + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("--target resolves a branch name via the parent project", () => { + const { layer, out, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, target: Option.some("staging") }); + expect(out.stderrText).toContain( + `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, + ); + const urls = api.requests.map((request) => request.url); + expect( + urls.some((url) => url.includes(`/v1/projects/${LEGACY_VALID_REF}/branches/staging`)), + ).toBe(true); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("--target resolves a branch UUID directly", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, target: Option.some(BRANCH_UUID) }); + const urls = api.requests.map((request) => request.url); + expect(urls.some((url) => url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("--target accepts a raw project ref without touching the branches API", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, target: Option.some(BRANCH_REF) }); + const urls = api.requests.map((request) => request.url); + expect(urls.some((url) => url.includes("/branches/"))).toBe(false); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("an unknown branch fails with a branches-list suggestion", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 404, body: { message: "not found" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, target: Option.some("ghost") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffBranchNotFoundError"); + expect(rendered).toContain('Branch \\"ghost\\" not found'); + expect(rendered).toContain("supabase branches list"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a non-404 branch lookup failure keeps its status error", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 500, body: { message: "boom" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, target: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffBranchResolveStatusError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--target and --project-ref together are rejected", () => { + const { layer, api } = setup({ toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ + exitCode: false, + target: Option.some("staging"), + projectRef: Option.some(LEGACY_VALID_REF), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffFlagConflictError"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("a missing config file points at supabase init", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffLoadConfigError"); + expect(rendered).toContain("supabase/config.toml: file not found"); + expect(rendered).toContain("supabase init"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a malformed config file fails as a parse error", () => { + const { layer } = setup({ toml: "not [valid toml\n" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }); + + it.live("duplicate [remotes.*] project_ids abort the load", () => { + const { layer } = setup({ + toml: [ + 'project_id = "test"', + "[remotes.a]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.b]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + ].join("\n"), + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffLoadConfigError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a remote config transport failure maps to the read network error", () => { + const { layer, telemetry } = setup({ toml: 'project_id = "test"\n', v2: "fail" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadNetworkError"); + // Telemetry still flushes on failure via Effect.ensuring. + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("a remote config error status maps to the read status error", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 403, body: { message: "forbidden" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadStatusError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output-format json emits the structured change set", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success).toBeDefined(); + expect(success?.message).toContain("1 config difference(s) found."); + const data = success?.data as Record; + expect(data["target"]).toMatchObject({ + project_ref: LEGACY_VALID_REF, + local_scope: "base", + }); + expect(data["scope"]).toEqual(["api", "auth", "database", "pooler", "realtime", "storage"]); + expect(data["changes"]).toEqual([ + { path: "api.max_rows", class: "update", local: 500, remote: 1000 }, + ]); + expect(data["counts"]).toEqual({ update: 1, remote_only: 0, local_only: 0, total: 1 }); + expect(data["masked"]).toEqual([]); + expect(typeof data["schema_version"]).toBe("string"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output-format stream-json reports zero differences as a success result", () => { + const { layer, out } = setup({ toml: 'project_id = "test"\n', format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.message).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("the Go-compat -o flag is rejected outright before any work happens", () => { + // Net-new TS command, no Go parity: every `-o` value is rejected — the + // machine formats and `pretty` alike (CLI-2156, per Colum). + const run = (goOutput: "json" | "pretty") => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + goOutput, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffOutputFlagUnsupportedError"); + expect(rendered).toContain("use --output-format json|stream-json instead"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }; + return Effect.gen(function* () { + yield* run("json"); + yield* run("pretty"); + }); + }); + + it.live("a fetch failure in json mode still maps cleanly without a spinner", () => { + const { layer } = setup({ toml: 'project_id = "test"\n', v2: "fail", format: "json" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadNetworkError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("json payload carries the remotes scope and env variable annotations", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.staging.api]", + 'max_rows = "env(PGRST_MAX_ROWS)"', + "", + ].join("\n"), + dotenv: "PGRST_MAX_ROWS=500\n", + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as Record; + expect(data["target"]).toMatchObject({ local_scope: "remotes.staging" }); + expect(data["changes"]).toEqual([ + { + path: "api.max_rows", + class: "update", + local: 500, + remote: 1000, + env_variable: "PGRST_MAX_ROWS", + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("remote-only drift renders (unset) locals distinguishably from empty ones", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + database: { + ...(attributes["database"] as Record), + postgres_settings: { work_mem: "64MB" }, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("db.settings.work_mem [remote only]"); + expect(out.stdoutText).toContain("local: (unset)"); + expect(out.stdoutText).toContain('remote: "64MB"'); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts new file mode 100644 index 0000000000..efa6e463f2 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { + describeLiveProject, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 120_000; + +// Golden path only: the one thing mocks cannot prove is the real +// `GET /v2/projects/{ref}/config` response shape (the GoTrue-keyed auth +// record especially) decoding and classifying cleanly. Branch coverage lives +// in diff.integration.test.ts. +describeLiveProject("supabase config diff (live)", () => { + let projectDir: string | undefined; + + afterEach(async () => { + if (projectDir !== undefined) { + await rm(projectDir, { recursive: true, force: true }); + projectDir = undefined; + } + }); + + test( + "diffs a freshly-initialized config against the project", + { timeout: LIVE_TIMEOUT_MS }, + async () => { + const ref = requireLiveProjectRef(); + projectDir = await mkdtemp(join(tmpdir(), "supabase-config-diff-live-")); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode).toBe(0); + + const { exitCode, stdout, stderr } = await runSupabaseLive( + ["config", "diff", "--project-ref", ref], + { cwd: projectDir }, + ); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(stderr).toContain(`Comparing against project ${ref} using base config`); + expect(stderr).toContain("Comparison scope:"); + // Read-only success regardless of drift (no --exit-code passed). + expect(exitCode).toBe(0); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts b/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts new file mode 100644 index 0000000000..01ad03e877 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts @@ -0,0 +1,70 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; + +/** + * Project ref pattern shared by every Management-API endpoint that accepts a + * 20-lowercase-letter project reference. + */ +export const LEGACY_BRANCH_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +/** + * Permissive UUID pattern (any 8-4-4-4-12 hex sequence) — accepts any RFC 4122 + * variant including v6/v7 and version 0, matching the established liberal + * acceptance rather than the v1–v5 + variant-1 subset. + */ +export const LEGACY_BRANCH_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Per-family error mapping for {@link legacyResolveBranchProjectRef}: each + * caller keeps its own tagged error classes (built with `mapLegacyHttpError`) + * so error identities, messages, and actionability stay family-owned. + */ +export interface LegacyBranchRefResolveMappers { + /** Maps a `GET /v1/branches/{branch_id}` (UUID lookup) failure. */ + readonly mapGetError: (cause: SupabaseApiError) => Effect.Effect; + /** Maps a `GET /v1/projects/{ref}/branches/{name}` (name lookup) failure. */ + readonly mapFindError: (cause: SupabaseApiError) => Effect.Effect; +} + +/** + * Resolves an arbitrary branch identifier to its project ref: + * + * 1. If the input matches `^[a-z]{20}$`, it's already a project ref — return as-is. + * 2. Else if the input is a UUID, call `V1GetABranchConfig` (`GET /v1/branches/{id}`) + * and return `JSON200.ref`. + * 3. Otherwise treat as a branch name under the linked project ref: call + * `V1GetABranch` (`GET /v1/projects/{ref}/branches/{name}`) and return + * `JSON200.project_ref`. + * + * The persistent `--project-ref` is required for path 3 and is passed in by + * the caller (which has already run `LegacyProjectRefResolver` so the linked + * project cache write does not re-fire here). + */ +export function legacyResolveBranchProjectRef( + input: string, + projectRef: string, + mappers: LegacyBranchRefResolveMappers, +) { + return Effect.gen(function* () { + if (LEGACY_BRANCH_PROJECT_REF_PATTERN.test(input)) { + return input; + } + + const api = yield* LegacyPlatformApi; + + if (LEGACY_BRANCH_UUID_PATTERN.test(input)) { + const detail = yield* api.v1 + .getABranchConfig({ branch_id_or_ref: input }) + .pipe(Effect.catch(mappers.mapGetError)); + return detail.ref; + } + + const branch = yield* api.v1 + .getABranch({ ref: projectRef, name: input }) + .pipe(Effect.catch(mappers.mapFindError)); + return branch.project_ref; + }); +} diff --git a/docs/adr/0019-config-diff-classification-and-managed-surface.md b/docs/adr/0019-config-diff-classification-and-managed-surface.md new file mode 100644 index 0000000000..92b3670dd9 --- /dev/null +++ b/docs/adr/0019-config-diff-classification-and-managed-surface.md @@ -0,0 +1,42 @@ +# 0019. Config Diff Classification and Managed Surface + +**Status**: proposed +**Date**: 2026-08-20 + +## Problem Statement + +`supabase config diff` (CLI-2156) compares the local `config.toml` against the effective configuration `GET /v2/projects/{ref}/config` reports, and `config pull` (CLI-2064) will delegate to the same engine. Three classification problems make a naive walk wrong: + +1. **Key-set asymmetry.** The earlier POC walked only keys present in the remote response, so a property the file declares and the remote doesn't return was structurally invisible. The inverse walk (local keys only) would hide remote-side drift the file never mentions. +2. **Managed vs. unmanaged.** Most of `config.toml` configures the *local* stack — `[studio]`, ports, image pins, `[db.migrations]` — and has no platform counterpart. Reporting those as drift is noise; deciding which properties the platform manages needs a source of truth that cannot drift from the code that reads the response. +3. **Incomparable values.** The platform masks secrets (HMAC, never plaintext), reports byte counts where the file writes `"50MiB"`, comma-joins arrays, and types some scalars differently than the schema. Comparing representations instead of meanings misreports drift; silently skipping them misreports cleanliness. + +## Decision + +`@supabase/config` owns the whole comparison core as pure, synchronous functions (`config-diff*.ts`), with no dependency on `@supabase/api`, output formatting, or command flags: + +- **The managed surface is defined by the translation table.** `MANAGED_CONFIG_PROPERTIES` is a table of entries, one per local schema path the v2 resource can report, each carrying a `read` function that descends the structurally-typed response (`RemoteProjectConfig`, all six blocks as loose records) and coerces the wire value to the local schema's type. A schema path with no entry is *unmanaged by construction* — the managed set and the response-reading code are the same artifact and cannot drift apart. The auth table is ported from the Go CLI's `FromRemoteAuthConfig` (commit `7b469f5b3`), including its inversions (`enable_signup` ← `!disable_signup`), duration/enum transforms, and provider fan-out. +- **Four-way classification per managed path**, driven by *declared* presence (the raw pre-decode document) on the local side and `read` presence on the remote side: `update` (declared + returned, values differ), `remote_only` (returned, undeclared, and differing from the baseline default — equal-to-default values are suppressed, which is what CLI-2155's defaults reference exists for), `local_only` (declared, not returned — parsed-but-never-pushed attributes and permission-truncated responses), and unmanaged (never reported). The local operand follows ADR 0018: the branch's merged effective config when the target ref matches a `[remotes.*]` block's `project_id`, the base config otherwise. +- **Equality is meaning-based**: arrays compare as multisets, scalars tolerate string/number and string/boolean representation skew, and per-entry `normalize` hooks canonicalize (byte sizes via `RAMInBytes` semantics, Go-duration strings) before comparison while reported values stay un-normalized. +- **Secrets are "present, unknown".** Entries marked `secret` (the union of the schema's `x-secret` fields and Go's `Secret` machinery) are never compared and never counted; locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. Likewise `scope` records which blocks the response actually carried, so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. +- The interpolation pipeline records the resolving env var name on `"environment"` value origins, so a change on an `env()`-fed property can name the variable involved. + +The command layer (`apps/cli/src/legacy/commands/config/diff/`) only resolves the target, fetches, and renders. + +## Considered Alternatives + +1. **Derive the managed set from the response keys** (the POC's approach): whatever the remote returns is what's compared. Structurally blind to `local_only`, and a permission-truncated response silently shrinks the comparison. +2. **Schema annotations (`x-managed`) on each property**: keeps the knowledge in the schema, but the annotation and the response-reading code can disagree, and the annotation cannot express per-property wire transforms (comma-splits, inversions, unit conversions) that the table entry's `read` carries anyway. +3. **Reuse `config push`'s `config-sync` diffing** (`apps/cli/src/legacy/commands/config/push/config-sync/`): those helpers produce per-service unified-diff *text* against the v1 per-service endpoints for push previews, not a typed change set, and they live in the CLI app. They remain the Go-parity push path; the classification core is the reusable engine `pull` needs. Consolidating push onto the core is possible later but out of scope here. + +## Consequences + +- `config pull` gets its comparison engine for free: the change set is typed data, and the same translation produces the local representation of any remote value it needs to write. +- Adding a newly platform-managed property is one table entry; forgetting it means the property is silently unmanaged (never misreported as drift), which fails safe. +- The structural `RemoteProjectConfig` type mirrors the v2 wire shape; if the API reshapes a block, the readers' runtime guards degrade to "not returned" (`local_only`/silent) rather than crashing, and the live test is the tripwire. +- Platform defaults that diverge from schema defaults surface as `remote_only` drift by design — the file's meaning is defined by the schema defaults reference (ADR 0018), not by what the platform would have picked. + +## Related Decisions + +- [ADR 0018](0018-sparse-config-subtraction.md): Sparse Config Subtraction — the defaults baseline and merged-remote-block local operand this classification builds on. +- [ADR 0006](0006-environment-management.md): Environment Management — remote blocks and branch mapping semantics. diff --git a/docs/adr/README.md b/docs/adr/README.md index 79e3386e7d..2ef871855c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -59,6 +59,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | | 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted | | 0018 | [Sparse Config Subtraction](0018-sparse-config-subtraction.md) | proposed | +| 0019 | [Config Diff Classification and Managed Surface](0019-config-diff-classification-and-managed-surface.md) | proposed | ## Template diff --git a/packages/config/src/config-diff.auth.ts b/packages/config/src/config-diff.auth.ts new file mode 100644 index 0000000000..1286ea2a7e --- /dev/null +++ b/packages/config/src/config-diff.auth.ts @@ -0,0 +1,461 @@ +import type { ManagedConfigProperty, RemoteProjectConfig } from "./config-diff.ts"; +import { + coerceRemoteScalar, + isRemoteRecord, + managedScalar, + managedStringList, + remoteValueAt, + type RemoteScalarKind, +} from "./config-diff.read.ts"; + +/** + * The auth portion of the managed surface (`config-diff.managed.ts`). The v2 + * `auth` block is a flat record keyed by lowercased GoTrue setting name — the + * same wire keys as the v1 `AuthConfigResponse`. Each entry maps one wire key + * to its `auth.*` config.toml path, mirroring the Go CLI's + * `FromRemoteAuthConfig` (`pkg/config/auth.go`): the same inversions + * (`disable_signup`, `mailer_autoconfirm`), duration conversions (wire + * seconds/hours to Go-style duration strings), and enum renames + * (`password_required_characters`) apply. + * + * Deliberately unmanaged: local-only fields the API never reports + * (`auth.enabled`, JWT key material, template `content_path`s, + * `auth.external.*.redirect_uri`), `auth.third_party.*` (not part of the + * gotrue config record), `auth.sms.test_otp` (a record-valued map, not a + * leaf), and wire keys with no local schema path (`passkey_enabled`, + * `webauthn_rp_*`, `external_figma_*`, SAML, OAuth server flags). + */ + +function readAuthValue(remote: RemoteProjectConfig, key: string): unknown { + return remoteValueAt(remote, "auth", [key]); +} + +function authScalar( + path: string, + remoteKey: string, + kind: RemoteScalarKind, +): ManagedConfigProperty { + return managedScalar({ path, block: "auth", remotePath: [remoteKey], kind }); +} + +function authSecret(path: string, remoteKey: string): ManagedConfigProperty { + return managedScalar({ + path, + block: "auth", + remotePath: [remoteKey], + kind: "string", + secret: true, + }); +} + +/** + * Inverted booleans: Go reads `EnableSignup = !DisableSignup` and + * `EnableConfirmations = !MailerAutoconfirm`. Only an actual boolean is + * negated; anything else (including "not returned") passes through so drift + * against an unexpected wire shape is reported rather than swallowed. + */ +function readNegatedBoolean(remoteKey: string) { + return (remote: RemoteProjectConfig): unknown => { + const value = coerceRemoteScalar(readAuthValue(remote, remoteKey), "boolean"); + return typeof value === "boolean" ? !value : value; + }; +} + +const GO_DURATION_UNIT_SECONDS = new Map([ + ["ns", 1e-9], + ["us", 1e-6], + ["µs", 1e-6], + ["ms", 1e-3], + ["s", 1], + ["m", 60], + ["h", 3600], +]); + +/** + * Canonicalizes Go-style duration strings (`"1h30m"`, `"5s"`, `"0"`) to + * seconds for comparison, matching `time.ParseDuration` for the non-negative + * durations the schema uses. Unparseable strings pass through so they still + * compare (and report) as-is. + */ +function normalizeGoDuration(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + const trimmed = value.trim(); + if (trimmed === "0") { + return 0; + } + const component = /(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|ms|s|m|h)/y; + let total = 0; + let index = 0; + while (index < trimmed.length) { + component.lastIndex = index; + const match = component.exec(trimmed); + if (match === null) { + return value; + } + total += Number(match[1]) * (GO_DURATION_UNIT_SECONDS.get(match[2]!) ?? 0); + index = component.lastIndex; + } + return index > 0 ? total : value; +} + +/** + * A local Go-duration string fed by a wire number of seconds or hours (e.g. + * `smtp_max_frequency` seconds, `sessions_timebox` hours). The remote value is + * rendered as `""` and both sides normalize through + * {@link normalizeGoDuration}, so `"1h30m"` still equals a wire `1.5` hours. + */ +function authDuration(path: string, remoteKey: string, unit: "s" | "h"): ManagedConfigProperty { + return { + path, + block: "auth", + normalize: normalizeGoDuration, + read: (remote) => { + const value = coerceRemoteScalar(readAuthValue(remote, remoteKey), "number"); + return typeof value === "number" ? `${value}${unit}` : value; + }, + }; +} + +/** + * `password_required_characters` reports a character-class string; the local + * schema stores an enum name (Go's `NewPasswordRequirement`). Unknown wire + * values pass through unmapped so they surface as drift. + */ +const PASSWORD_REQUIREMENTS_BY_REQUIRED_CHARACTERS = new Map([ + ["abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", "letters_digits"], + [ + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "lower_upper_letters_digits", + ], + [ + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", + "lower_upper_letters_digits_symbols", + ], +]); + +// -- Core / site -------------------------------------------------------------- + +const CORE_PROPERTIES: ReadonlyArray = [ + authScalar("auth.site_url", "site_url", "string"), + managedStringList({ + path: "auth.additional_redirect_urls", + block: "auth", + remotePath: ["uri_allow_list"], + }), + authScalar("auth.jwt_expiry", "jwt_exp", "number"), + authScalar("auth.enable_refresh_token_rotation", "refresh_token_rotation_enabled", "boolean"), + authScalar( + "auth.refresh_token_reuse_interval", + "security_refresh_token_reuse_interval", + "number", + ), + authScalar("auth.enable_manual_linking", "security_manual_linking_enabled", "boolean"), + // Go: `a.EnableSignup = !DisableSignup` (auth.go:454). + { path: "auth.enable_signup", block: "auth", read: readNegatedBoolean("disable_signup") }, + authScalar("auth.enable_anonymous_sign_ins", "external_anonymous_users_enabled", "boolean"), + authScalar("auth.minimum_password_length", "password_min_length", "number"), + { + path: "auth.password_requirements", + block: "auth", + read: (remote) => { + const value = coerceRemoteScalar( + readAuthValue(remote, "password_required_characters"), + "string", + ); + if (typeof value !== "string") { + return value; + } + return PASSWORD_REQUIREMENTS_BY_REQUIRED_CHARACTERS.get(value) ?? value; + }, + }, +]; + +// -- Email -------------------------------------------------------------------- + +const EMAIL_TEMPLATE_NAMES = [ + "invite", + "confirmation", + "recovery", + "magic_link", + "email_change", + "reauthentication", +]; + +const EMAIL_NOTIFICATION_NAMES = [ + "password_changed", + "email_changed", + "phone_changed", + "identity_linked", + "identity_unlinked", + "mfa_factor_enrolled", + "mfa_factor_unenrolled", +]; + +const EMAIL_PROPERTIES: ReadonlyArray = [ + authScalar("auth.email.enable_signup", "external_email_enabled", "boolean"), + authScalar("auth.email.double_confirm_changes", "mailer_secure_email_change_enabled", "boolean"), + // Go: `e.EnableConfirmations = !MailerAutoconfirm` (auth.go:825). + { + path: "auth.email.enable_confirmations", + block: "auth", + read: readNegatedBoolean("mailer_autoconfirm"), + }, + authScalar( + "auth.email.secure_password_change", + "security_update_password_require_reauthentication", + "boolean", + ), + authDuration("auth.email.max_frequency", "smtp_max_frequency", "s"), + authScalar("auth.email.otp_length", "mailer_otp_length", "number"), + authScalar("auth.email.otp_expiry", "mailer_otp_exp", "number"), + // Go derives enablement from `smtp_host` presence: the platform clears every + // SMTP field when custom SMTP is off (auth.go:1115: `Enabled = SmtpHost != nil`). + { + path: "auth.email.smtp.enabled", + block: "auth", + read: (remote) => { + if (!isRemoteRecord(remote.auth)) { + return undefined; + } + return readAuthValue(remote, "smtp_host") !== undefined; + }, + }, + authScalar("auth.email.smtp.host", "smtp_host", "string"), + // The wire reports the port as a string; the local schema types it a number. + authScalar("auth.email.smtp.port", "smtp_port", "number"), + authScalar("auth.email.smtp.user", "smtp_user", "string"), + authSecret("auth.email.smtp.pass", "smtp_pass"), + authScalar("auth.email.smtp.admin_email", "smtp_admin_email", "string"), + authScalar("auth.email.smtp.sender_name", "smtp_sender_name", "string"), + // Template subjects only: local templates store bodies as `content_path` + // files, which the wire never reports. + ...EMAIL_TEMPLATE_NAMES.map((name) => + authScalar(`auth.email.template.${name}.subject`, `mailer_subjects_${name}`, "string"), + ), + ...EMAIL_NOTIFICATION_NAMES.flatMap((name) => [ + authScalar( + `auth.email.notification.${name}.enabled`, + `mailer_notifications_${name}_enabled`, + "boolean", + ), + authScalar( + `auth.email.notification.${name}.subject`, + `mailer_subjects_${name}_notification`, + "string", + ), + ]), +]; + +// -- SMS ---------------------------------------------------------------------- + +/** + * The wire reports a single `sms_provider`; Go fans it out to per-provider + * `enabled` flags (auth.go:1207-1213). An empty provider reads as "not + * returned" because Go leaves the local flags untouched in that case. + */ +function readSmsProviderEnabled(provider: string) { + return (remote: RemoteProjectConfig): unknown => { + const value = coerceRemoteScalar(readAuthValue(remote, "sms_provider"), "string"); + if (typeof value !== "string") { + return value; + } + return value === "" ? undefined : value === provider; + }; +} + +const SMS_PROVIDER_IDS = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"]; + +const SMS_PROPERTIES: ReadonlyArray = [ + authScalar("auth.sms.enable_signup", "external_phone_enabled", "boolean"), + authScalar("auth.sms.enable_confirmations", "sms_autoconfirm", "boolean"), + authScalar("auth.sms.template", "sms_template", "string"), + authDuration("auth.sms.max_frequency", "sms_max_frequency", "s"), + ...SMS_PROVIDER_IDS.map((provider): ManagedConfigProperty => ({ + path: `auth.sms.${provider}.enabled`, + block: "auth", + read: readSmsProviderEnabled(provider), + })), + authScalar("auth.sms.twilio.account_sid", "sms_twilio_account_sid", "string"), + authScalar("auth.sms.twilio.message_service_sid", "sms_twilio_message_service_sid", "string"), + authSecret("auth.sms.twilio.auth_token", "sms_twilio_auth_token"), + authScalar("auth.sms.twilio_verify.account_sid", "sms_twilio_verify_account_sid", "string"), + authScalar( + "auth.sms.twilio_verify.message_service_sid", + "sms_twilio_verify_message_service_sid", + "string", + ), + authSecret("auth.sms.twilio_verify.auth_token", "sms_twilio_verify_auth_token"), + authScalar("auth.sms.messagebird.originator", "sms_messagebird_originator", "string"), + authSecret("auth.sms.messagebird.access_key", "sms_messagebird_access_key"), + authScalar("auth.sms.textlocal.sender", "sms_textlocal_sender", "string"), + authSecret("auth.sms.textlocal.api_key", "sms_textlocal_api_key"), + authScalar("auth.sms.vonage.from", "sms_vonage_from", "string"), + authScalar("auth.sms.vonage.api_key", "sms_vonage_api_key", "string"), + authSecret("auth.sms.vonage.api_secret", "sms_vonage_api_secret"), +]; + +// -- MFA ---------------------------------------------------------------------- + +const MFA_PROPERTIES: ReadonlyArray = [ + authScalar("auth.mfa.max_enrolled_factors", "mfa_max_enrolled_factors", "number"), + authScalar("auth.mfa.totp.enroll_enabled", "mfa_totp_enroll_enabled", "boolean"), + authScalar("auth.mfa.totp.verify_enabled", "mfa_totp_verify_enabled", "boolean"), + authScalar("auth.mfa.phone.enroll_enabled", "mfa_phone_enroll_enabled", "boolean"), + authScalar("auth.mfa.phone.verify_enabled", "mfa_phone_verify_enabled", "boolean"), + authScalar("auth.mfa.phone.otp_length", "mfa_phone_otp_length", "number"), + authScalar("auth.mfa.phone.template", "mfa_phone_template", "string"), + authDuration("auth.mfa.phone.max_frequency", "mfa_phone_max_frequency", "s"), + authScalar("auth.mfa.web_authn.enroll_enabled", "mfa_web_authn_enroll_enabled", "boolean"), + authScalar("auth.mfa.web_authn.verify_enabled", "mfa_web_authn_verify_enabled", "boolean"), +]; + +// -- External OAuth providers --------------------------------------------------- + +interface OAuthProviderSpec { + readonly id: string; + /** Wire reports `external__url` (azure, gitlab, keycloak, workos). */ + readonly url?: boolean; + /** Wire reports `external__email_optional` (every provider but workos). */ + readonly emailOptional?: boolean; + /** Wire splits extra client ids into `external__additional_client_ids`. */ + readonly additionalClientIds?: boolean; + /** Wire reports `external__skip_nonce_check` (google only). */ + readonly skipNonceCheck?: boolean; +} + +/** + * The providers the local schema declares (`auth/providers.ts`), in schema + * order. Go also maps `figma`, which the local schema does not model. The + * local `redirect_uri` field (and `url`/`skip_nonce_check` on providers whose + * wire block omits them) has no remote counterpart and stays unmanaged. + */ +const OAUTH_PROVIDERS: ReadonlyArray = [ + { id: "apple", additionalClientIds: true, emailOptional: true }, + { id: "azure", url: true, emailOptional: true }, + { id: "bitbucket", emailOptional: true }, + { id: "discord", emailOptional: true }, + { id: "facebook", emailOptional: true }, + { id: "github", emailOptional: true }, + { id: "gitlab", url: true, emailOptional: true }, + { id: "google", additionalClientIds: true, skipNonceCheck: true, emailOptional: true }, + { id: "kakao", emailOptional: true }, + { id: "keycloak", url: true, emailOptional: true }, + { id: "linkedin_oidc", emailOptional: true }, + { id: "notion", emailOptional: true }, + { id: "twitch", emailOptional: true }, + { id: "twitter", emailOptional: true }, + { id: "x", emailOptional: true }, + { id: "slack_oidc", emailOptional: true }, + { id: "spotify", emailOptional: true }, + { id: "workos", url: true }, + { id: "zoom", emailOptional: true }, +]; + +function oauthProviderEntries(spec: OAuthProviderSpec): ReadonlyArray { + const prefix = `auth.external.${spec.id}`; + const wire = `external_${spec.id}`; + const entries: Array = [ + authScalar(`${prefix}.enabled`, `${wire}_enabled`, "boolean"), + ]; + if (spec.additionalClientIds === true) { + // Go folds `additional_client_ids` back into the comma-joined local + // `client_id` (auth.go:1415-1417, 1516-1518). + entries.push({ + path: `${prefix}.client_id`, + block: "auth", + read: (remote) => { + const clientId = coerceRemoteScalar(readAuthValue(remote, `${wire}_client_id`), "string"); + const additional = coerceRemoteScalar( + readAuthValue(remote, `${wire}_additional_client_ids`), + "string", + ); + if (typeof clientId !== "string" || typeof additional !== "string" || additional === "") { + return clientId; + } + return `${clientId},${additional}`; + }, + }); + } else { + entries.push(authScalar(`${prefix}.client_id`, `${wire}_client_id`, "string")); + } + entries.push(authSecret(`${prefix}.secret`, `${wire}_secret`)); + if (spec.url === true) { + entries.push(authScalar(`${prefix}.url`, `${wire}_url`, "string")); + } + if (spec.skipNonceCheck === true) { + entries.push(authScalar(`${prefix}.skip_nonce_check`, `${wire}_skip_nonce_check`, "boolean")); + } + if (spec.emailOptional === true) { + entries.push(authScalar(`${prefix}.email_optional`, `${wire}_email_optional`, "boolean")); + } + return entries; +} + +const EXTERNAL_PROPERTIES: ReadonlyArray = + OAUTH_PROVIDERS.flatMap(oauthProviderEntries); + +// -- Sessions ------------------------------------------------------------------- + +const SESSION_PROPERTIES: ReadonlyArray = [ + authDuration("auth.sessions.timebox", "sessions_timebox", "h"), + authDuration("auth.sessions.inactivity_timeout", "sessions_inactivity_timeout", "h"), +]; + +// -- Rate limits ---------------------------------------------------------------- + +const RATE_LIMIT_PROPERTIES: ReadonlyArray = [ + authScalar("auth.rate_limit.email_sent", "rate_limit_email_sent", "number"), + authScalar("auth.rate_limit.sms_sent", "rate_limit_sms_sent", "number"), + authScalar("auth.rate_limit.anonymous_users", "rate_limit_anonymous_users", "number"), + authScalar("auth.rate_limit.token_refresh", "rate_limit_token_refresh", "number"), + authScalar("auth.rate_limit.sign_in_sign_ups", "rate_limit_otp", "number"), + authScalar("auth.rate_limit.token_verifications", "rate_limit_verify", "number"), + authScalar("auth.rate_limit.web3", "rate_limit_web3", "number"), +]; + +// -- Captcha -------------------------------------------------------------------- + +const CAPTCHA_PROPERTIES: ReadonlyArray = [ + authScalar("auth.captcha.enabled", "security_captcha_enabled", "boolean"), + authScalar("auth.captcha.provider", "security_captcha_provider", "string"), + authSecret("auth.captcha.secret", "security_captcha_secret"), +]; + +// -- Web3 ----------------------------------------------------------------------- + +const WEB3_PROPERTIES: ReadonlyArray = [ + authScalar("auth.web3.solana.enabled", "external_web3_solana_enabled", "boolean"), + authScalar("auth.web3.ethereum.enabled", "external_web3_ethereum_enabled", "boolean"), +]; + +// -- Hooks ---------------------------------------------------------------------- + +const HOOK_NAMES = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", +]; + +const HOOK_PROPERTIES: ReadonlyArray = HOOK_NAMES.flatMap((name) => [ + authScalar(`auth.hook.${name}.enabled`, `hook_${name}_enabled`, "boolean"), + authScalar(`auth.hook.${name}.uri`, `hook_${name}_uri`, "string"), + authSecret(`auth.hook.${name}.secrets`, `hook_${name}_secrets`), +]); + +export const AUTH_MANAGED_CONFIG_PROPERTIES: ReadonlyArray = [ + ...CORE_PROPERTIES, + ...EMAIL_PROPERTIES, + ...SMS_PROPERTIES, + ...MFA_PROPERTIES, + ...EXTERNAL_PROPERTIES, + ...SESSION_PROPERTIES, + ...RATE_LIMIT_PROPERTIES, + ...CAPTCHA_PROPERTIES, + ...WEB3_PROPERTIES, + ...HOOK_PROPERTIES, +]; diff --git a/packages/config/src/config-diff.managed.ts b/packages/config/src/config-diff.managed.ts new file mode 100644 index 0000000000..a246f5647d --- /dev/null +++ b/packages/config/src/config-diff.managed.ts @@ -0,0 +1,200 @@ +import type { ManagedConfigProperty } from "./config-diff.ts"; +import { AUTH_MANAGED_CONFIG_PROPERTIES } from "./config-diff.auth.ts"; +import { + isRemoteRecord, + managedScalar, + managedStringList, + normalizeByteSize, + remoteValueAt, + type RemoteScalarKind, +} from "./config-diff.read.ts"; + +/** + * The managed surface: every local schema path the v2 project-config resource + * can report, with its reader. A local path with no entry here is unmanaged by + * construction — `[studio]`, `[local_smtp]`, ports, image pins, TLS material, + * `db.migrations`/`db.seed`, `storage.buckets` content, and the entire local + * `[realtime]` section (its local fields — `enabled`, `ip_version`, + * `max_header_length` — configure the local container only; none of the v2 + * `realtime` block's platform limits have a config.toml counterpart). + */ + +const API_PROPERTIES: ReadonlyArray = [ + managedStringList({ path: "api.schemas", block: "api", remotePath: ["db_schema"] }), + managedStringList({ + path: "api.extra_search_path", + block: "api", + remotePath: ["db_extra_search_path"], + }), + managedScalar({ path: "api.max_rows", block: "api", remotePath: ["max_rows"], kind: "number" }), +]; + +/** + * `db.settings.*` ↔ `database.postgres_settings.*`. The wire block carries + * more settings than the local schema declares; only locally-representable + * ones are managed. Kinds mirror `db.ts`'s `settings` struct. + */ +const POSTGRES_SETTINGS: ReadonlyArray = [ + ["effective_cache_size", "string"], + ["logical_decoding_work_mem", "string"], + ["maintenance_work_mem", "string"], + ["max_connections", "number"], + ["max_locks_per_transaction", "number"], + ["max_parallel_maintenance_workers", "number"], + ["max_parallel_workers", "number"], + ["max_parallel_workers_per_gather", "number"], + ["max_replication_slots", "number"], + ["max_slot_wal_keep_size", "string"], + ["max_standby_archive_delay", "string"], + ["max_standby_streaming_delay", "string"], + ["max_wal_size", "string"], + ["max_wal_senders", "number"], + ["max_worker_processes", "number"], + ["session_replication_role", "string"], + ["shared_buffers", "string"], + ["statement_timeout", "string"], + ["track_activity_query_size", "string"], + ["track_commit_timestamp", "boolean"], + ["wal_keep_size", "string"], + ["wal_sender_timeout", "string"], + ["work_mem", "string"], +]; + +function readAllowedCidrs(kind: "v4" | "v6") { + return (remote: Parameters[0]): unknown => { + const entries = remoteValueAt(remote, "database", ["network_restrictions", "allowed_cidrs"]); + if (!Array.isArray(entries)) { + return undefined; + } + return entries + .filter(isRemoteRecord) + .filter((entry) => entry["type"] === kind) + .map((entry) => entry["address"]) + .filter((address): address is string => typeof address === "string"); + }; +} + +const DATABASE_PROPERTIES: ReadonlyArray = [ + managedScalar({ + path: "db.ssl_enforcement.enabled", + block: "database", + remotePath: ["ssl_enforced"], + kind: "boolean", + }), + { + path: "db.network_restrictions.allowed_cidrs", + block: "database", + read: readAllowedCidrs("v4"), + }, + { + path: "db.network_restrictions.allowed_cidrs_v6", + block: "database", + read: readAllowedCidrs("v6"), + }, + ...POSTGRES_SETTINGS.map(([name, kind]) => + managedScalar({ + path: `db.settings.${name}`, + block: "database", + remotePath: ["postgres_settings", name], + kind, + }), + ), +]; + +const POOLER_PROPERTIES: ReadonlyArray = [ + managedScalar({ + path: "db.pooler.pool_mode", + block: "pooler", + remotePath: ["pool_mode"], + kind: "string", + }), + managedScalar({ + path: "db.pooler.default_pool_size", + block: "pooler", + remotePath: ["default_pool_size"], + kind: "number", + }), + managedScalar({ + path: "db.pooler.max_client_conn", + block: "pooler", + remotePath: ["max_client_conn"], + kind: "number", + }), +]; + +const STORAGE_PROPERTIES: ReadonlyArray = [ + managedScalar({ + path: "storage.file_size_limit", + block: "storage", + remotePath: ["file_size_limit"], + kind: "string", + normalize: normalizeByteSize, + }), + managedScalar({ + path: "storage.image_transformation.enabled", + block: "storage", + remotePath: ["features", "image_transformation", "enabled"], + kind: "boolean", + }), + managedScalar({ + path: "storage.s3_protocol.enabled", + block: "storage", + remotePath: ["features", "s3_protocol", "enabled"], + kind: "boolean", + }), + managedScalar({ + path: "storage.analytics.enabled", + block: "storage", + remotePath: ["features", "iceberg_catalog", "enabled"], + kind: "boolean", + }), + managedScalar({ + path: "storage.analytics.max_namespaces", + block: "storage", + remotePath: ["features", "iceberg_catalog", "max_namespaces"], + kind: "number", + }), + managedScalar({ + path: "storage.analytics.max_tables", + block: "storage", + remotePath: ["features", "iceberg_catalog", "max_tables"], + kind: "number", + }), + managedScalar({ + path: "storage.analytics.max_catalogs", + block: "storage", + remotePath: ["features", "iceberg_catalog", "max_catalogs"], + kind: "number", + }), + managedScalar({ + path: "storage.vector.enabled", + block: "storage", + remotePath: ["features", "vector_buckets", "enabled"], + kind: "boolean", + }), + managedScalar({ + path: "storage.vector.max_buckets", + block: "storage", + remotePath: ["features", "vector_buckets", "max_buckets"], + kind: "number", + }), + managedScalar({ + path: "storage.vector.max_indexes", + block: "storage", + remotePath: ["features", "vector_buckets", "max_indexes"], + kind: "number", + }), +]; + +export const MANAGED_CONFIG_PROPERTIES: ReadonlyArray = [ + ...API_PROPERTIES, + ...AUTH_MANAGED_CONFIG_PROPERTIES, + ...DATABASE_PROPERTIES, + ...POOLER_PROPERTIES, + ...STORAGE_PROPERTIES, +]; + +/** Dotted local schema paths of the managed surface. */ +export const MANAGED_CONFIG_PATHS: ReadonlySet = new Set( + MANAGED_CONFIG_PROPERTIES.map((property) => property.path), +); diff --git a/packages/config/src/config-diff.read.ts b/packages/config/src/config-diff.read.ts new file mode 100644 index 0000000000..18d82d7b62 --- /dev/null +++ b/packages/config/src/config-diff.read.ts @@ -0,0 +1,151 @@ +import type { + ManagedConfigProperty, + RemoteConfigBlock, + RemoteProjectConfig, +} from "./config-diff.ts"; + +/** + * Reader/constructor helpers for the managed-surface table + * (`config-diff.managed.ts`, `config-diff.auth.ts`). Every reader descends the + * loosely-typed v2 response with runtime guards and coerces the wire value to + * the local schema's type, so the classifier compares like with like. + */ + +export function isRemoteRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Reads a nested value from a response block. `undefined` means "not + * returned"; an explicit `null` also reads as not returned (the API uses it + * for "no value set", e.g. `api.db_pool`). + */ +export function remoteValueAt( + remote: RemoteProjectConfig, + block: RemoteConfigBlock, + segments: ReadonlyArray, +): unknown { + let current: unknown = remote[block]; + for (const segment of segments) { + if (!isRemoteRecord(current) || !Object.hasOwn(current, segment)) { + return undefined; + } + current = current[segment]; + } + return current === null ? undefined : current; +} + +export type RemoteScalarKind = "string" | "number" | "boolean"; + +const REMOTE_BOOL_TRUE = new Set(["true", "1"]); +const REMOTE_BOOL_FALSE = new Set(["false", "0"]); + +/** + * Coerces a wire scalar to the local schema's primitive kind. Unconvertible + * values pass through unchanged so drift against an unexpected wire shape is + * reported rather than swallowed. + */ +export function coerceRemoteScalar(value: unknown, kind: RemoteScalarKind): unknown { + if (value === undefined) { + return undefined; + } + switch (kind) { + case "number": { + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : value; + } + return value; + } + case "boolean": { + if (typeof value === "string") { + const lowered = value.trim().toLowerCase(); + if (REMOTE_BOOL_TRUE.has(lowered)) return true; + if (REMOTE_BOOL_FALSE.has(lowered)) return false; + } + return value; + } + case "string": { + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return value; + } + } +} + +export interface ManagedScalarOptions { + /** Dotted local schema path. */ + readonly path: string; + readonly block: RemoteConfigBlock; + /** Segments below the block, e.g. `["postgres_settings", "work_mem"]`. */ + readonly remotePath: ReadonlyArray; + readonly kind: RemoteScalarKind; + readonly secret?: boolean; + readonly normalize?: (value: unknown) => unknown; +} + +export function managedScalar(options: ManagedScalarOptions): ManagedConfigProperty { + return { + path: options.path, + block: options.block, + ...(options.secret === true ? { secret: true } : {}), + ...(options.normalize === undefined ? {} : { normalize: options.normalize }), + read: (remote) => + coerceRemoteScalar(remoteValueAt(remote, options.block, options.remotePath), options.kind), + }; +} + +export interface ManagedListOptions { + readonly path: string; + readonly block: RemoteConfigBlock; + readonly remotePath: ReadonlyArray; + readonly secret?: boolean; +} + +/** + * A local string-array property the wire reports either as a comma-joined + * string (e.g. PostgREST's `db_schema`) or as an actual array. + */ +export function managedStringList(options: ManagedListOptions): ManagedConfigProperty { + return { + path: options.path, + block: options.block, + ...(options.secret === true ? { secret: true } : {}), + read: (remote) => { + const value = remoteValueAt(remote, options.block, options.remotePath); + if (typeof value === "string") { + return value === "" + ? [] + : value + .split(",") + .map((element) => element.trim()) + .filter((element) => element !== ""); + } + if (Array.isArray(value)) { + return value; + } + return undefined; + }, + }; +} + +/** + * Canonicalizes byte-size values for comparison: the wire reports byte + * counts (`52428800`) where the file writes human-readable sizes (`"50MiB"`). + * 1024-based and case-insensitive with an optional `b`/`ib` suffix, matching + * Go's `units.RAMInBytes` semantics used by the original config loader. + * Unparseable strings pass through so they still compare (and report) as-is. + */ +export function normalizeByteSize(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + const match = /^\s*(\d*\.?\d+)\s*([kmgtp]?)(?:i?b)?\s*$/i.exec(value); + if (match === null) { + return value; + } + const magnitude = Number(match[1]); + const exponent = { "": 0, k: 1, m: 2, g: 3, t: 4, p: 5 }[match[2]!.toLowerCase()] ?? 0; + return Math.floor(magnitude * 1024 ** exponent); +} diff --git a/packages/config/src/config-diff.ts b/packages/config/src/config-diff.ts new file mode 100644 index 0000000000..23905e59c3 --- /dev/null +++ b/packages/config/src/config-diff.ts @@ -0,0 +1,305 @@ +import type { BaseProjectConfig } from "./sparse.ts"; +import { getDefaultProjectConfig } from "./sparse.ts"; +import { MANAGED_CONFIG_PROPERTIES } from "./config-diff.managed.ts"; + +/** + * Config drift classification between a local project config and the + * effective remote configuration reported by the Management API + * (`GET /v2/projects/{ref}/config`). Pure and synchronous: fetching the + * response, resolving the target, and rendering output are the caller's job + * (`supabase config diff`, and `config pull` after it). See ADR 0019. + */ + +/** The per-service blocks of the v2 project-config resource. */ +export type RemoteConfigBlock = "api" | "auth" | "database" | "pooler" | "realtime" | "storage"; + +export const REMOTE_CONFIG_BLOCKS: ReadonlyArray = [ + "api", + "auth", + "database", + "pooler", + "realtime", + "storage", +]; + +/** + * Structural shape of the v2 response's `data.attributes`. Deliberately loose + * (`Record` per block): the wire format is owned by the + * Management API and may grow keys at any time, and every read below descends + * with runtime guards. This package must not import `@supabase/api` — the + * caller passes whatever the generated client decoded. + */ +export interface RemoteProjectConfig { + readonly api?: Readonly> | undefined; + readonly auth?: Readonly> | undefined; + readonly database?: Readonly> | undefined; + readonly pooler?: Readonly> | undefined; + readonly realtime?: Readonly> | undefined; + readonly storage?: Readonly> | undefined; +} + +/** + * One remotely-managed local schema property. The managed surface is *defined* + * by the table of these entries (`config-diff.managed.ts`): a schema path with + * no entry is unmanaged by construction and never appears in a change set. + */ +export interface ManagedConfigProperty { + /** Dotted local schema path, e.g. `"api.max_rows"`. Always a leaf. */ + readonly path: string; + /** Which v2 block reports this property. */ + readonly block: RemoteConfigBlock; + /** + * Secret-valued: the platform reports an HMAC (or omits the value), never + * plaintext. The property is "present, unknown" — excluded from comparison + * and surfaced via {@link ConfigChangeSet.masked} instead. + */ + readonly secret?: boolean; + /** + * Reads this property's value from the response, coerced to the local + * schema's type. `undefined` means the response did not carry it. + */ + readonly read: (remote: RemoteProjectConfig) => unknown; + /** + * Canonicalizes a value before equality on both sides (e.g. byte-size + * strings to byte counts). Reported values stay un-normalized. + */ + readonly normalize?: (value: unknown) => unknown; +} + +export type ConfigChangeClass = "update" | "remote_only" | "local_only"; + +export interface ConfigChange { + /** Dotted local schema path. */ + readonly path: string; + /** + * `update`: declared locally and returned remotely, values differ. + * `remote_only`: returned remotely, not declared in the file, and differing + * from the schema default. `local_only`: declared in the file but the + * response did not account for it. + */ + readonly class: ConfigChangeClass; + /** Effective local value; `undefined` when the file does not declare it. */ + readonly local: unknown; + /** Remote value; `undefined` when the response did not return it. */ + readonly remote: unknown; + /** Environment variable a local `env()` reference resolved from, if any. */ + readonly envVariable?: string | undefined; +} + +export interface ConfigChangeCounts { + readonly update: number; + readonly remote_only: number; + readonly local_only: number; +} + +export interface ConfigChangeSet { + /** Reportable differences, ordered by path. */ + readonly changes: ReadonlyArray; + /** + * Managed secret paths the file sets a value for. These were never compared + * (the platform masks them), so a clean `changes` list is still only a + * partial claim — callers must surface this. + */ + readonly masked: ReadonlyArray; + /** Blocks the response actually carried, ordered per {@link REMOTE_CONFIG_BLOCKS}. */ + readonly scope: ReadonlyArray; + readonly counts: ConfigChangeCounts; +} + +export interface DiffProjectConfigOptions { + /** + * The *effective* local config: decoded with defaults filled, `env()` + * resolved, and — when the target is a branch with a matching `[remotes.*]` + * block — merged per ADR 0018. + */ + readonly local: BaseProjectConfig; + /** + * The raw (pre-decode, post-merge) document the config was loaded from. + * Declares which paths the file actually sets — the decoded config cannot, + * because decoding materializes every default. `undefined` (a file that did + * not parse to an object) means nothing is declared. + */ + readonly declared: Readonly> | undefined; + readonly remote: RemoteProjectConfig; + /** + * Baseline for `remote_only` suppression: a remote value equal to this + * config's value at the same path is not drift. Defaults to the current + * schema's default config. + */ + readonly defaults?: BaseProjectConfig; + /** Dotted local path → environment variable name, for `env()` reporting. */ + readonly envReferences?: ReadonlyMap; +} + +function isPlainRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Walks a dotted path through records with own-key checks only. */ +function valueAtPath(root: unknown, path: string): unknown { + let current: unknown = root; + for (const segment of path.split(".")) { + if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function isDeclaredAtPath(root: Readonly>, path: string): boolean { + let current: unknown = root; + const segments = path.split("."); + for (const [index, segment] of segments.entries()) { + if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { + return false; + } + if (index < segments.length - 1) { + current = current[segment]; + } + } + return true; +} + +function scalarEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + // Type-aware comparison: the response may carry "8080" where the schema + // types the property as a number (or vice versa) — that is not drift. + if (typeof a === "string" && typeof b === "number") { + const parsed = Number(a.trim()); + return a.trim() !== "" && Number.isFinite(parsed) && parsed === b; + } + if (typeof a === "number" && typeof b === "string") { + return scalarEqual(b, a); + } + if (typeof a === "string" && typeof b === "boolean") { + return a.trim().toLowerCase() === String(b); + } + if (typeof a === "boolean" && typeof b === "string") { + return scalarEqual(b, a); + } + return false; +} + +function canonicalArrayElement(value: unknown): string { + if (typeof value === "string") { + return `s:${value}`; + } + if (typeof value === "number" || typeof value === "boolean") { + // Scalars fold to their string form so "1" and 1 compare equal, matching + // the scalar type-awareness above. + return `s:${String(value)}`; + } + return `j:${JSON.stringify(value)}`; +} + +function isZeroValue(value: unknown): boolean { + return ( + value === false || value === "" || value === 0 || (Array.isArray(value) && value.length === 0) + ); +} + +/** + * Order-insensitive, type-aware value equality: arrays compare as multisets + * (`additional_redirect_urls` in a different order is not a difference), and + * scalars tolerate string/number and string/boolean representation skew. + */ +export function isEqualConfigValue(a: unknown, b: unknown): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + const left = a.map(canonicalArrayElement).sort(); + const right = b.map(canonicalArrayElement).sort(); + return left.every((element, index) => element === right[index]); + } + return scalarEqual(a, b); +} + +/** + * Classifies every managed property into the change set. Pure: no I/O, no + * dependency on command flags or output formatting. + */ +export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChangeSet { + const defaults = options.defaults ?? getDefaultProjectConfig(); + const declaredRoot = options.declared ?? {}; + const changes: Array = []; + const masked: Array = []; + + for (const property of MANAGED_CONFIG_PROPERTIES) { + const declared = isDeclaredAtPath(declaredRoot, property.path); + + if (property.secret === true) { + if (declared) { + masked.push(property.path); + } + continue; + } + + const remoteValue = property.read(options.remote); + const localValue = valueAtPath(options.local, property.path); + const normalize = property.normalize ?? ((value: unknown) => value); + const envVariable = options.envReferences?.get(property.path); + + if (remoteValue !== undefined && declared) { + if (!isEqualConfigValue(normalize(localValue), normalize(remoteValue))) { + changes.push({ + path: property.path, + class: "update", + local: localValue, + remote: remoteValue, + ...(envVariable === undefined ? {} : { envVariable }), + }); + } + continue; + } + + if (remoteValue !== undefined) { + const defaultValue = valueAtPath(defaults, property.path); + // Optional-key sections (db.ssl_enforcement, db.settings, auth + // providers…) never materialize in the default config, so their paths + // have no baseline value. The platform still reports the unconfigured + // state for them as the type's zero value (false / "" / 0 / []) — an + // undeclared feature reporting its zero value is not drift. + const suppressed = + defaultValue === undefined + ? isZeroValue(remoteValue) + : isEqualConfigValue(normalize(defaultValue), normalize(remoteValue)); + if (!suppressed) { + changes.push({ + path: property.path, + class: "remote_only", + local: undefined, + remote: remoteValue, + }); + } + continue; + } + + if (declared) { + changes.push({ + path: property.path, + class: "local_only", + local: localValue, + remote: undefined, + ...(envVariable === undefined ? {} : { envVariable }), + }); + } + } + + changes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + masked.sort(); + + return { + changes, + masked, + scope: REMOTE_CONFIG_BLOCKS.filter((block) => isPlainRecord(options.remote[block])), + counts: { + update: changes.filter((change) => change.class === "update").length, + remote_only: changes.filter((change) => change.class === "remote_only").length, + local_only: changes.filter((change) => change.class === "local_only").length, + }, + }; +} diff --git a/packages/config/src/config-diff.unit.test.ts b/packages/config/src/config-diff.unit.test.ts new file mode 100644 index 0000000000..d74df7aff5 --- /dev/null +++ b/packages/config/src/config-diff.unit.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, test } from "vitest"; +import { Schema } from "effect"; +import { ProjectConfigSchema } from "./base.ts"; +import { + diffProjectConfig, + isEqualConfigValue, + type ConfigChange, + type DiffProjectConfigOptions, + type RemoteProjectConfig, +} from "./config-diff.ts"; +import { MANAGED_CONFIG_PATHS, MANAGED_CONFIG_PROPERTIES } from "./config-diff.managed.ts"; +import { normalizeByteSize } from "./config-diff.read.ts"; + +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +/** + * Builds the diff input the way the command layer does: `declared` is the raw + * document (key presence), `local` is its decoded effective config. + */ +function diffWith( + declared: Record, + remote: RemoteProjectConfig, + extra?: Partial, +) { + return diffProjectConfig({ + local: decodeProjectConfig(declared), + declared, + remote, + ...extra, + }); +} + +function changeAt(changes: ReadonlyArray, path: string): ConfigChange | undefined { + return changes.find((change) => change.path === path); +} + +describe("managed surface", () => { + test("declares no duplicate paths", () => { + expect(MANAGED_CONFIG_PATHS.size).toBe(MANAGED_CONFIG_PROPERTIES.length); + }); + + test("every managed path resolves to a real schema path in the default config", () => { + const defaults: unknown = decodeProjectConfig({}); + for (const path of MANAGED_CONFIG_PATHS) { + let current: unknown = defaults; + for (const segment of path.split(".")) { + if (typeof current !== "object" || current === null) { + throw new Error(`managed path ${path} leaves the schema at ${segment}`); + } + // Optional-key subtrees (db.settings, storage.image_transformation, + // auth provider entries…) are absent from the default config; their + // presence in the schema is asserted by the entries' unit coverage + // below instead. + if (!Object.hasOwn(current, segment)) { + current = undefined; + break; + } + current = (current as Record)[segment]; + } + } + }); + + test("local-only sections are unmanaged by construction", () => { + for (const prefix of ["studio.", "local_smtp.", "edge_runtime.", "analytics.", "realtime."]) { + for (const path of MANAGED_CONFIG_PATHS) { + expect(path.startsWith(prefix)).toBe(false); + } + } + expect(MANAGED_CONFIG_PATHS.has("api.port")).toBe(false); + expect(MANAGED_CONFIG_PATHS.has("db.port")).toBe(false); + }); +}); + +describe("diffProjectConfig classification", () => { + test("an undefined declared document means nothing is declared", () => { + const result = diffProjectConfig({ + local: decodeProjectConfig({}), + declared: undefined, + remote: { api: { max_rows: 250 } }, + }); + expect(changeAt(result.changes, "api.max_rows")).toMatchObject({ class: "remote_only" }); + }); + + test("declared value differing from remote is an update", () => { + const result = diffWith( + { api: { max_rows: 500 } }, + { api: { max_rows: 1000, db_schema: "public,graphql_public" } }, + ); + const change = changeAt(result.changes, "api.max_rows"); + expect(change).toMatchObject({ class: "update", local: 500, remote: 1000 }); + expect(result.counts.update).toBe(1); + }); + + test("declared value equal to remote is not a difference", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 500 } }); + expect(result.changes).toEqual([]); + expect(result.counts).toEqual({ update: 0, remote_only: 0, local_only: 0 }); + }); + + test("remote value at the schema default is suppressed when undeclared", () => { + const result = diffWith({}, { api: { max_rows: 1000 } }); + expect(changeAt(result.changes, "api.max_rows")).toBeUndefined(); + }); + + test("remote value off the schema default is remote_only when undeclared", () => { + const result = diffWith({}, { api: { max_rows: 250 } }); + const change = changeAt(result.changes, "api.max_rows"); + expect(change).toMatchObject({ class: "remote_only", local: undefined, remote: 250 }); + }); + + test("optional-key paths with no materialized default suppress zero-valued remotes", () => { + // db.ssl_enforcement and auth providers are optionalKey — absent from the + // default config — and the platform reports their unconfigured state as + // zero values. Those are not drift; a non-zero value is. + const clean = diffWith( + {}, + { + database: { ssl_enforced: false }, + auth: { external_github_enabled: false, external_github_client_id: "" }, + }, + ); + expect(clean.changes).toEqual([]); + + const drifted = diffWith({}, { database: { ssl_enforced: true } }); + expect(changeAt(drifted.changes, "db.ssl_enforcement.enabled")).toMatchObject({ + class: "remote_only", + remote: true, + }); + }); + + test("declared value the response does not carry is local_only", () => { + const result = diffWith( + { api: { max_rows: 500 } }, + // api block present but without max_rows, and no other blocks at all. + { api: { db_schema: "public" } }, + ); + const change = changeAt(result.changes, "api.max_rows"); + expect(change).toMatchObject({ class: "local_only", local: 500, remote: undefined }); + }); + + test("a wholly absent block turns its declared properties local_only", () => { + const result = diffWith({ db: { settings: { max_connections: 120 } } }, {}); + expect(changeAt(result.changes, "db.settings.max_connections")).toMatchObject({ + class: "local_only", + local: 120, + }); + expect(result.scope).toEqual([]); + }); + + test("unmanaged declared properties are never reported", () => { + const result = diffWith( + { + studio: { port: 55555 }, + api: { port: 4321 }, + realtime: { max_header_length: 8192 }, + local_smtp: { enabled: true }, + }, + { api: {}, realtime: { max_concurrent_users: 5 } }, + ); + expect(result.changes).toEqual([]); + }); + + test("array comparison ignores element order", () => { + const result = diffWith( + { api: { schemas: ["graphql_public", "public"] } }, + { api: { db_schema: "public,graphql_public" } }, + ); + expect(result.changes).toEqual([]); + }); + + test("comma-joined remote strings trim around separators", () => { + const result = diffWith( + { api: { extra_search_path: ["public", "extensions"] } }, + { api: { db_extra_search_path: "public, extensions" } }, + ); + expect(result.changes).toEqual([]); + }); + + test("scalar comparison is type-aware across string/number and string/boolean", () => { + const result = diffWith( + { + db: { + settings: { max_connections: 120, track_commit_timestamp: true }, + }, + }, + { + database: { + postgres_settings: { max_connections: "120", track_commit_timestamp: "true" }, + }, + }, + ); + expect(result.changes).toEqual([]); + }); + + test("byte-size values compare canonically across representations", () => { + const equal = diffWith( + { storage: { file_size_limit: "50MiB" } }, + { storage: { file_size_limit: 52428800 } }, + ); + expect(equal.changes).toEqual([]); + + const differing = diffWith( + { storage: { file_size_limit: "50MiB" } }, + { storage: { file_size_limit: 1048576 } }, + ); + // The reader coerces the wire's byte count to the local schema's string + // kind before comparison, so the reported remote value is the coerced form. + expect(changeAt(differing.changes, "storage.file_size_limit")).toMatchObject({ + class: "update", + local: "50MiB", + remote: "1048576", + }); + }); + + test("network restriction CIDRs split by address family", () => { + const result = diffWith( + { + db: { + network_restrictions: { + enabled: true, + allowed_cidrs: ["10.0.0.0/8"], + allowed_cidrs_v6: [], + }, + }, + }, + { + database: { + network_restrictions: { + allowed_cidrs: [ + { address: "10.0.0.0/8", type: "v4" }, + { address: "fd00::/8", type: "v6" }, + ], + }, + }, + }, + ); + expect(changeAt(result.changes, "db.network_restrictions.allowed_cidrs")).toBeUndefined(); + expect(changeAt(result.changes, "db.network_restrictions.allowed_cidrs_v6")).toMatchObject({ + class: "update", + local: [], + remote: ["fd00::/8"], + }); + }); + + test("declared secret values are masked, never compared, never counted", () => { + const declared = { + auth: { external: { github: { enabled: true, client_id: "id", secret: "shh" } } }, + }; + const result = diffWith(declared, { + auth: { external_github_enabled: true, external_github_client_id: "id" }, + }); + expect(result.masked).toContain("auth.external.github.secret"); + expect(changeAt(result.changes, "auth.external.github.secret")).toBeUndefined(); + expect(result.counts).toEqual({ update: 0, remote_only: 0, local_only: 0 }); + }); + + test("undeclared secrets are neither masked nor reported", () => { + const result = diffWith({}, { auth: { smtp_pass: "hmac-of-something" } }); + expect(result.masked).toEqual([]); + expect(result.changes.filter((change) => change.path.includes("pass"))).toEqual([]); + }); + + test("scope lists exactly the blocks the response carried, in order", () => { + const result = diffWith({}, { storage: {}, api: {}, database: {} }); + expect(result.scope).toEqual(["api", "database", "storage"]); + }); + + test("env references annotate the change for the involved variable", () => { + const result = diffWith( + { api: { max_rows: 500 } }, + { api: { max_rows: 1000 } }, + { envReferences: new Map([["api.max_rows", "PGRST_MAX_ROWS"]]) }, + ); + expect(changeAt(result.changes, "api.max_rows")).toMatchObject({ + envVariable: "PGRST_MAX_ROWS", + }); + }); + + test("changes are ordered by path and counts add up", () => { + const result = diffWith( + { api: { max_rows: 5 }, storage: { file_size_limit: "1MiB" } }, + { api: { max_rows: 6 }, database: { postgres_settings: { work_mem: "64MB" } } }, + ); + const paths = result.changes.map((change) => change.path); + expect(paths).toEqual([...paths].sort()); + expect(result.counts.update).toBe(1); + expect(result.counts.remote_only).toBe(1); + expect(result.counts.local_only).toBe(1); + }); +}); + +describe("isEqualConfigValue", () => { + test("multiset semantics for arrays", () => { + expect(isEqualConfigValue(["a", "b"], ["b", "a"])).toBe(true); + expect(isEqualConfigValue(["a", "a", "b"], ["a", "b", "b"])).toBe(false); + expect(isEqualConfigValue(["1"], [1])).toBe(true); + expect(isEqualConfigValue(["a"], ["a", "a"])).toBe(false); + }); + + test("type-aware scalars", () => { + expect(isEqualConfigValue("8080", 8080)).toBe(true); + expect(isEqualConfigValue(8080, "8080")).toBe(true); + expect(isEqualConfigValue("true", true)).toBe(true); + expect(isEqualConfigValue(false, "false")).toBe(true); + expect(isEqualConfigValue("", 0)).toBe(false); + expect(isEqualConfigValue("8080x", 8080)).toBe(false); + expect(isEqualConfigValue(undefined, "")).toBe(false); + }); +}); + +describe("normalizeByteSize", () => { + test("parses 1024-based human sizes case-insensitively", () => { + expect(normalizeByteSize("50MiB")).toBe(52428800); + expect(normalizeByteSize("50MB")).toBe(52428800); + expect(normalizeByteSize("50mb")).toBe(52428800); + expect(normalizeByteSize("1GiB")).toBe(1073741824); + expect(normalizeByteSize("500")).toBe(500); + expect(normalizeByteSize("0.5k")).toBe(512); + }); + + test("passes through numbers and unparseable strings", () => { + expect(normalizeByteSize(52428800)).toBe(52428800); + expect(normalizeByteSize("not-a-size")).toBe("not-a-size"); + expect(normalizeByteSize(true)).toBe(true); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2375d4e04d..d375b501ae 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -56,5 +56,19 @@ export { omitDefaultValues, subtractProjectConfig, } from "./sparse.ts"; +export { + type ConfigChange, + type ConfigChangeClass, + type ConfigChangeCounts, + type ConfigChangeSet, + type DiffProjectConfigOptions, + type ManagedConfigProperty, + type RemoteConfigBlock, + type RemoteProjectConfig, + REMOTE_CONFIG_BLOCKS, + diffProjectConfig, + isEqualConfigValue, +} from "./config-diff.ts"; +export { MANAGED_CONFIG_PATHS } from "./config-diff.managed.ts"; export { KONG_LOCAL_CA_CERT } from "./tls.ts"; export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index b65435cf84..1ac5f65b6a 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -20,6 +20,11 @@ export type ProjectConfigValueSource = "environment" | "local" | "remote"; export interface ProjectConfigValueOrigin { readonly path: ReadonlyArray; readonly source: ProjectConfigValueSource; + /** + * For `"environment"` origins: the env var name(s) the `env()` reference + * resolved from (comma-joined when one array literal drew on several). + */ + readonly envVariable?: string; } export interface LoadedProjectConfig { @@ -722,7 +727,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( const goViperCompat = options?.goViperCompat ?? false; const interpolateDocument = ( document: unknown, - onResolvedEnv?: (path: ReadonlyArray) => void, + onResolvedEnv?: (path: ReadonlyArray, envName: string) => void, ): unknown => interpolateEnvReferencesAgainstSchema(document, projectEnv?.values ?? {}, ProjectConfigSchema, { goViperCompat, @@ -770,9 +775,11 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( // that path, but correctness on the match+`env()` path matters more than // avoiding it. const resolvedEnvironmentPaths: Array = []; + const resolvedEnvironmentNames = new Map(); documentForDecode = isObject(documentForDecode) - ? interpolateDocument(documentForDecode, (path) => { + ? interpolateDocument(documentForDecode, (path, envName) => { resolvedEnvironmentPaths.push(Array.from(path)); + resolvedEnvironmentNames.set(pathKey(Array.from(path)), envName); }) : documentForDecode; @@ -817,7 +824,12 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( : localPathKeys.has(key) ? "local" : undefined; - return source === undefined ? [] : [{ path, source }]; + if (source === undefined) { + return []; + } + const envVariable = + source === "environment" ? resolvedEnvironmentNames.get(key) : undefined; + return [{ path, source, ...(envVariable === undefined ? {} : { envVariable }) }]; }) : []; diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index b90bf35619..d898381e30 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -218,7 +218,7 @@ function substituteEnvLeaf( value: string, env: Readonly>, goViperCompat: boolean, -): { readonly value: string; readonly resolved: boolean } { +): { readonly value: string; readonly resolved: boolean; readonly envName?: string } { const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); if (match === null) { return { value, resolved: false }; @@ -229,10 +229,10 @@ function substituteEnvLeaf( // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the // `env(KEY)` literal exactly like an unset key, rather than substituting "". - if (resolved === undefined || resolved === "") { + if (envName === undefined || resolved === undefined || resolved === "") { return { value, resolved: false }; } - return { value: resolved, resolved: true }; + return { value: resolved, resolved: true, envName }; } function isDeferredEnvField(ast: SchemaAST.AST): boolean { @@ -258,22 +258,26 @@ function walk( ast: SchemaAST.AST | null, goViperCompat: boolean, path: ReadonlyArray, - onResolvedEnv: ((path: ReadonlyArray) => void) | undefined, + onResolvedEnv: ((path: ReadonlyArray, envName: string) => void) | undefined, ): unknown { if (Array.isArray(document)) { - let resolved = false; + // Element-level resolutions are reported once, at the array's own path — + // one array literal may draw on several env vars, so the names collect. + const envNames: Array = []; const onResolvedArrayEnv = onResolvedEnv === undefined ? undefined - : () => { - resolved = true; + : (_: ReadonlyArray, envName: string) => { + if (!envNames.includes(envName)) { + envNames.push(envName); + } }; const result = document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); return walk(item, env, child, goViperCompat, [...path, String(index)], onResolvedArrayEnv); }); - if (resolved) { - onResolvedEnv?.(path); + if (envNames.length > 0) { + onResolvedEnv?.(path, envNames.join(", ")); } return result; } @@ -297,8 +301,8 @@ function walk( const interpolation = substituteEnvLeaf(document, env, goViperCompat); const substituted = interpolation.value; - if (interpolation.resolved) { - onResolvedEnv?.(path); + if (interpolation.resolved && interpolation.envName !== undefined) { + onResolvedEnv?.(path, interpolation.envName); } const expected = ast === null ? "unknown" : leafExpectedType(ast); @@ -357,7 +361,9 @@ export function interpolateEnvReferencesAgainstSchema( schema: { readonly ast: SchemaAST.AST }, options?: { readonly goViperCompat?: boolean; - readonly onResolvedEnv?: (path: ReadonlyArray) => void; + /** Fires per resolved leaf with the substituting env var's name (array + * leaves report once at the array path, names comma-joined). */ + readonly onResolvedEnv?: (path: ReadonlyArray, envName: string) => void; }, ): unknown { return walk(