diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 77f19458ce..746416baa6 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -212,6 +212,29 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("a disabled Data API diffs clean against a declared enabled = false", () => { + const { layer, out, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nenabled = false\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + api: { + ...(attributes["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("a configured provider still diffs clean", () => { const { layer, out } = setup({ toml: [ diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 30f0a36ff4..eb4c7722a6 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -603,6 +603,31 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("a disabled Data API leaves a declared enabled = false untouched", () => { + const before = 'project_id = "test"\n[api]\nenabled = false\n'; + const { layer, out } = setup({ + toml: before, + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + api: { + ...(attributes["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPull(noFlags); + expect(yield* readConfig).toBe(before); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + it.live("declining the confirmation prompt leaves the file unchanged", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; const { layer, out } = setup({ diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 4cddc7ba2e..391bd6def0 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -560,6 +560,57 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); + it.live("reports up-to-date when the project already has the Data API disabled", () => { + const { layer, out, api } = setup({ + toml: 'project_id = "test"\n[api]\nenabled = false\n', + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { + ...(a["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Remote API config is up to date."); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("re-enables a disabled Data API with the default schemas", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n[api]\nenabled = true\n', + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { + ...(a["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find((r) => r.method === "PATCH" && r.url.includes("/postgrest")); + expect(update).toBeDefined(); + expect(update?.body).toMatchObject({ db_schema: "public,graphql_public" }); + }).pipe(Effect.provide(layer)); + }); + it.live("stops a service when the user declines the prompt (exit 0)", () => { const { layer, out, api } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, diff --git a/docs/adr/0021-projectconfig-convergence-semantics.md b/docs/adr/0021-projectconfig-convergence-semantics.md index 3b052badfb..a835dbf4ca 100644 --- a/docs/adr/0021-projectconfig-convergence-semantics.md +++ b/docs/adr/0021-projectconfig-convergence-semantics.md @@ -245,9 +245,9 @@ inert/unrepresentable server-side when the container's own toggle is off, indepe actor's write path — rather than "what legacy push happened to send"**: - `api.{schemas,extra_search_path,max_rows}`: `api.enabled` isn't an independent wire field at all — - the registry derives it from `db_schema.length > 0`, the same fact `schemas` itself carries, and - the API arm already row-gates all three siblings on that same check independently. Symmetry with - something the API arm enforces on its own, not push imitation. + the registry derives it from `db_schema` via `remoteDataApiDisabled`, the same fact `schemas` + itself carries, and the API arm already row-gates all three siblings on that same check + independently. Symmetry with something the API arm enforces on its own, not push imitation. - `db.network_restrictions.{allowed_cidrs,allowed_cidrs_v6}`: the flag's own schema description is "Enable **management** of network restrictions," not "enable network restrictions" — a deliberate, actor-independent opt-out with no v2 API field at all (the one entry with no API-arm diff --git a/docs/adr/0022-config-diff-classification-and-managed-surface.md b/docs/adr/0022-config-diff-classification-and-managed-surface.md index 46ca73e61e..25fc6a6937 100644 --- a/docs/adr/0022-config-diff-classification-and-managed-surface.md +++ b/docs/adr/0022-config-diff-classification-and-managed-surface.md @@ -22,7 +22,7 @@ This ADR was first accepted with a self-contained translation table inside `conf - **Three-way classification per comparable path**, driven by _declared_ presence (the raw pre-decode document), which a decoded config cannot recover: `update` (declared + reported, values differ), `remote_only` (reported while undeclared — or while the feature it belongs to is switched off and no actor's write path can express the declared state — and differing from the suppression baseline), `local_only` (a declared local projection value the response did not account for: parsed-but-never-pushed attributes and permission-truncated responses). 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. - **`remote_only` suppression baseline**: the default config's own convergence projection, falling back — for push-gated containers the projection is silent on — to the raw default config's value (`db.network_restrictions`' allow-all default IS the platform's unconfigured state), then to the registry row's declared `unconfiguredValue` (the platform's own report of an unconfigured feature — GoTrue's `sessions_timebox: 0` canonicalizes to the STRING `"0s"`, pinned by the recorded `config_auth` fixtures). Platform-RENDERED strings (mailer template/notification subjects) get no pinned baseline at all: the platform authors those values and rewords them across environments, so a pinned copy cannot be simultaneously correct for prod and staging — their rows declare `platformRendered` instead, and any remote value is suppressed while the local projection is silent (a locally declared subject still classifies normally). "Unconfigured" is never inferred from type-level zero values — canonicalization can turn a platform zero into a non-zero shape, and PR #6295's review reproduced 15 noise lines on an untouched staging project from exactly that inference. A path with no baseline at any tier reports rather than guesses (over-report over under-report). An unconfigured project therefore diffs clean instead of flooding with platform-default noise. - **Equality is meaning-based**: the normalizers canonicalize representations (durations, byte sizes, comma-joins) per ADR 0021, and the classifier's residual equality tolerates string/number and string/boolean scalar skew. Whether an array is a SET or a SEQUENCE is per-field wire knowledge and lives on the registry row (`arrayEquality`), defaulting to sequence — `api.schemas`' first entry is PostgREST's default schema and `api.extra_search_path` is a literal `search_path`, so reordering is drift; `auth.additional_redirect_urls` opts into set semantics. -- **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. This family is no longer whole-container omissions predicting what a push mapper happened to send (that mechanism, `applyPushUnmanagedOmissions`, was retired on CLI-2314 — see ADR 0021's 2026-09-04 addendum for the full re-derivation); it is now `DISABLED_SENTINEL_PRUNES`'s surviving sibling-only rules, each re-derived from a platform data-model fact independent of any actor's write path — e.g. the Data-API-off `api.{schemas,extra_search_path,max_rows}` siblings (`api.enabled` is itself derived from `db_schema.length > 0`, the same fact `schemas` carries), `auth.email.smtp`'s non-host fields (`smtp.enabled` is likewise derived from `smtp_host.length > 0`, not an independent field), `auth.captcha.provider`/`secret` (a disabled captcha's provider choice is genuinely retained-but-inert on the platform), `auth.oauth_server`'s two non-`enabled` siblings, `storage.analytics`/`storage.vector`'s quota fields, and `db.network_restrictions`'s CIDR fields (a document-only management opt-out with no v2 API counterpart at all). The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. This resource is read via `api.executeRaw` rather than the generated client's `execute`, per ADR 0019 rule 2: a strict `Schema.Struct` decode would drop excess properties and reject unknown enum members (a new `pooler.pool_mode` value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — `diff.integration.test.ts`'s "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, `config pull` included, must read it the same way, or deliberately reintroduce contract overrides for it. +- **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. This family is no longer whole-container omissions predicting what a push mapper happened to send (that mechanism, `applyPushUnmanagedOmissions`, was retired on CLI-2314 — see ADR 0021's 2026-09-04 addendum for the full re-derivation); it is now `DISABLED_SENTINEL_PRUNES`'s surviving sibling-only rules, each re-derived from a platform data-model fact independent of any actor's write path — e.g. the Data-API-off `api.{schemas,extra_search_path,max_rows}` siblings (`api.enabled` is itself derived from `db_schema` via `remoteDataApiDisabled`, the same fact `schemas` carries), `auth.email.smtp`'s non-host fields (`smtp.enabled` is likewise derived from `smtp_host.length > 0`, not an independent field), `auth.captcha.provider`/`secret` (a disabled captcha's provider choice is genuinely retained-but-inert on the platform), `auth.oauth_server`'s two non-`enabled` siblings, `storage.analytics`/`storage.vector`'s quota fields, and `db.network_restrictions`'s CIDR fields (a document-only management opt-out with no v2 API counterpart at all). The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. This resource is read via `api.executeRaw` rather than the generated client's `execute`, per ADR 0019 rule 2: a strict `Schema.Struct` decode would drop excess properties and reject unknown enum members (a new `pooler.pool_mode` value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — `diff.integration.test.ts`'s "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, `config pull` included, must read it the same way, or deliberately reintroduce contract overrides for it. - **Change entries carry what a consumer needs verbatim**: `path` is a SEGMENT ARRAY end to end (an `sms.test_otp` phone-number key may itself contain a `.`; joining is display-only), `declared` distinguishes file-written values from schema-materialized defaults, and a `remote_only` entry keeps the materialized local default so "what would `config push` change?" is answerable from the change alone — the primary dashboard-drift use case, and what `config pull` will need to write back. - **Command surface (review revision, PR #6295)**: the target flag is `link`'s settled vocabulary — a single `--project-ref` accepting a project ref or a branch name/UUID (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke `--target`. The global `-o/--output` flag is rejected outright — every value, `pretty` included — with a message pointing at `--output-format` (the CLI-2156 ruling: no Go-compat contract for net-new commands, recorded as the scoped exception to Legacy Shell Invariant #6). `--exit-code` gives drift its own exit code `2`, keeping `1` for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (`schema_version: 1`, an integer) separately from the user-controlled `$schema` document reference (`config_schema`). - 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. diff --git a/packages/config/src/project-config/project-config.ts b/packages/config/src/project-config/project-config.ts index b719d47d67..456f131a2e 100644 --- a/packages/config/src/project-config/project-config.ts +++ b/packages/config/src/project-config/project-config.ts @@ -369,7 +369,7 @@ export const DISABLED_SENTINEL_PRUNES: ReadonlyArray<{ /** Keys to drop when `enabled === false`; absent = drop every key but `enabled`. */ readonly dropKeys?: ReadonlyArray; }> = [ - // `api.enabled` derives from `schemas.length > 0`; `extra_search_path`/`max_rows` configure a + // `api.enabled` and `schemas` share one `db_schema`; `extra_search_path`/`max_rows` configure a // PostgREST that isn't exposed while the Data API is off. { containerPath: ["api"], dropKeys: ["schemas", "extra_search_path", "max_rows"] }, // `enabled` here means "manage network restrictions", a management opt-out — the platform has diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index 4961bf83ea..e7cf72ab42 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -724,14 +724,18 @@ describe("fromApiProjectConfig — api section", () => { expect(result.api).toEqual({ schemas: ["public", "graphql_public"], enabled: true }); }); - // An explicit `db_schema: ""` is the remote's disable sentinel, so only `enabled: false` is - // reported; an absent `db_schema` does not gate the siblings. - test("an empty db_schema disables the Data API and omits the sibling fields", () => { - const result = fromApiProjectConfig({ - api: { db_schema: "", db_extra_search_path: "public", max_rows: 100 }, - }); - expect(result.api).toEqual({ enabled: false }); - }); + // An explicit `db_schema: ""` and the platform's `pg_pgrst_no_exposed_schemas` marker are the + // remote's disable sentinels, so only `enabled: false` is reported; an absent `db_schema` does + // not gate the siblings. + test.each(["", "pg_pgrst_no_exposed_schemas"])( + "db_schema %j disables the Data API and omits the sibling fields", + (dbSchema) => { + const result = fromApiProjectConfig({ + api: { db_schema: dbSchema, db_extra_search_path: "public", max_rows: 100 }, + }); + expect(result.api).toEqual({ enabled: false }); + }, + ); test("max_rows is clamped to zero when negative", () => { const result = fromApiProjectConfig({ api: { max_rows: -5 } }); diff --git a/packages/config/src/project-config/registry.ts b/packages/config/src/project-config/registry.ts index a0e4cd2ae8..dbce597062 100644 --- a/packages/config/src/project-config/registry.ts +++ b/packages/config/src/project-config/registry.ts @@ -26,13 +26,16 @@ const apiExtraSearchPathPath = ["api", "db_extra_search_path"]; const apiMaxRowsPath = ["api", "max_rows"]; /** - * True when the remote reports the Data API disabled: an explicit `""` `db_schema`, not an - * absent one (which means the sparse input simply didn't mention it). The rows below gate on - * this so a disabled remote maps to exactly `{ api: { enabled: false } }`. + * True when the remote reports the Data API disabled: an explicit `""` `db_schema` or the + * platform's `pg_pgrst_no_exposed_schemas` marker, not an absent one (which means the sparse + * input simply didn't mention it). The marker can't name a real schema because Postgres + * reserves the `pg_` prefix. The rows below gate on this so a disabled remote maps to exactly + * `{ api: { enabled: false } }`. */ function remoteDataApiDisabled(attributes: Record): boolean { const api = attributes["api"]; - return isObject(api) && api["db_schema"] === ""; + const dbSchema = isObject(api) ? api["db_schema"] : undefined; + return dbSchema === "" || dbSchema === "pg_pgrst_no_exposed_schemas"; } /** @@ -79,7 +82,10 @@ const apiSectionRows: ReadonlyArray = [ // `apiPath` (see registry-row.ts). configPath: ["api", "enabled"], apiPath: apiDbSchemaPath, - transform: (value) => expectString(value, apiDbSchemaPath).length > 0, + transform: (value, attributes) => { + expectString(value, apiDbSchemaPath); + return !remoteDataApiDisabled(attributes); + }, }, { configPath: ["api", "extra_search_path"],