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 index 8cdbaa0302..2ce52fb13f 100644 --- a/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts @@ -138,7 +138,7 @@ function v2Response( external_phone_enabled: false, sms_autoconfirm: false, sms_max_frequency: 5, - sms_otp_exp: 600, + sms_otp_exp: 60, sms_otp_length: 6, external_github_enabled: false, external_github_client_id: "", diff --git a/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts index 981872b5da..1c3269f32f 100644 --- a/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts @@ -201,7 +201,7 @@ function v2Response( external_phone_enabled: false, sms_autoconfirm: false, sms_max_frequency: 5, - sms_otp_exp: 600, + sms_otp_exp: 60, sms_otp_length: 6, external_github_enabled: false, external_github_client_id: "", diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts index 95409dde1e..3677c2e472 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts @@ -50,6 +50,8 @@ const baseEnvInput: LegacyBuildGotrueEnvInput = { enable_confirmations: false, template: "Your code is {{ .Code }}", max_frequency: "5s", + otp_length: 6, + otp_expiry: 60, twilio: { enabled: false, account_sid: "", message_service_sid: "" }, twilio_verify: { enabled: false }, messagebird: { enabled: false }, diff --git a/docs/adr/0021-projectconfig-convergence-semantics.md b/docs/adr/0021-projectconfig-convergence-semantics.md index f9570c55cf..2918f64fbe 100644 --- a/docs/adr/0021-projectconfig-convergence-semantics.md +++ b/docs/adr/0021-projectconfig-convergence-semantics.md @@ -82,6 +82,32 @@ Concrete behavior families, by normalizer: ships the push-side capability — an omission this family models is a statement about push's CURRENT limitations, not a permanent semantic ceiling. +- **CLI-only fields with no hosted counterpart on EITHER arm** (CLI-2316, `DOCUMENT_ONLY_LOCAL_PATHS`, + `project-config.ts`) — a family DISTINCT from the unmanaged-by-push family above, despite the + similar-looking mechanism (both drop a document-declared value from the projection): this family's + omissions are a PERMANENT semantic ceiling, not a push-capability gap CLI-2266's lockstep rule + governs. `api.port`/`api.tls`/`api.external_url`, `db.port`/`db.shadow_port`/`db.health_timeout`, + `db.pooler.{enabled,port}`, `db.migrations`, `db.seed`, every config-side `realtime.*` field, and + most of `experimental.*` describe purely local-machine behavior — a bind port, which Docker image to + run, which local schema-diff engine to use — with no corresponding `v2GetProjectConfig` attribute to + ever converge toward, confirmed directly against the OpenAPI-generated schema + (`packages/api/src/generated/contracts.ts`), not assumed. There is no "push gains the capability" + future for these: a hosted project has no port to bind or Docker image to select, so unlike the + family above, extending push could never make one of these comparable. + + **A field that DOES have a `v2GetProjectConfig` counterpart is never a member of this family, even + when `config push` cannot write it** (PR #6451 review round, correcting this family's initial + version): `db.major_version` and `db.pooler.{pool_mode,default_pool_size,max_client_conn}` are real, + read-only-via-push hosted facts — `fromApiProjectConfig` maps all four from genuine + `v2GetProjectConfig` state — and excluding them from the document arm made them permanently + `unmanaged` in `config diff` for every stock project (the `supabase init` template declares all + four), since `config diff`/`config pull` — `ProjectConfig`'s actual current consumers — never even + reach the comparison for an `unmanaged` path (ADR 0022). `config push` itself doesn't consult + `ProjectConfig` at all today (still the legacy v1 `config-sync` mappers below), so "push can't write + it" is not, by itself, a reason to exclude a field from this list — only "no hosted fact exists to + compare or pull" is. Distinguishing "unpushable" from "not hosted at all" for `config push`'s own + future consumption of `ProjectConfig` is explicitly CLI-2313/CLI-2314's concern, not this list's. + **`fromApiProjectConfig`** (`registry-auth.ts`, `registry.ts`, `project-config.ts`): - API `null` on a gating boolean canonicalizes to `enabled: false` rather than being skipped, so the 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 3af2ee07c1..41a1a38619 100644 --- a/docs/adr/0022-config-diff-classification-and-managed-surface.md +++ b/docs/adr/0022-config-diff-classification-and-managed-surface.md @@ -18,7 +18,7 @@ This ADR was first accepted with a self-contained translation table inside `conf `@supabase/config` owns the comparison core as pure, synchronous functions (`config-diff.ts`), with no dependency on `@supabase/api`, output formatting, or command flags — layered on CLI-2230's registry rather than a translation of its own. It is CLI-only today: `diffProjectConfig` is exported solely from the `internal` subpath, which `monorepo-import-contract.unit.test.ts` restricts to `apps/cli` — Studio has no path to it. Promotion to the public entrypoint is deliberately deferred to a follow-up when a second consumer (Studio, or a push mapper) actually needs it, rather than exported speculatively: - **Both operands are `ProjectConfig` convergence projections (ADR 0021).** The caller builds the local operand with `fromConfigDocument({config, document})` — raw-presence-masked, canonicalized, secret-omitting — and the remote operand with `fromApiProjectConfig(response)`. All wire-shape knowledge (renames, inversions, unit conversions, the GoTrue key table) lives in `projectConfigMappingRows`, once, shared with Studio and the future push mapper. -- **The managed surface is the registry's.** The classifier walks the union of both operands' leaf paths filtered by `isComparableProjectConfigPath` — a path with no registry row is _unmanaged by construction_ and never reported (`[studio]`, ports, image pins, `[realtime]` locals, `workers`). +- **The managed surface is the registry's.** The classifier walks the union of both operands' leaf paths filtered by `isComparableProjectConfigPath` — a path with no registry row is _unmanaged by construction_ and never reported (`[studio]`, ports, image pins, `[realtime]` locals, `workers`). Most of these same paths are ALSO excluded one layer earlier, from `ProjectConfig`'s own shape (`DOCUMENT_ONLY_LOCAL_PATHS`, ADR 0021, CLI-2316) — a `ProjectConfig`-contract-level guarantee for consumers other than this classifier (Studio, `unmappedApiFields`), not a duplicate of this rule. The two mechanisms must never diverge in the direction that matters here: a comparable (registry-mapped) path must never ALSO be a `DOCUMENT_ONLY_LOCAL_PATHS` member, or it silently becomes permanently `unmanaged` below regardless of whether `fromApiProjectConfig` reports a real value for it — `project-config.unit.test.ts`'s "no comparableProjectConfigPaths entry ... is ever a DOCUMENT_ONLY_LOCAL_PATHS member" test pins this (added after `db.major_version`/`db.pooler.{pool_mode,default_pool_size,max_client_conn}` briefly violated it — PR #6451 review round, ADR 0021's CLI-2316 addendum). - **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 push cannot communicate 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. diff --git a/packages/config/src/auth/providers.ts b/packages/config/src/auth/providers.ts index 4e7691d117..375fb48df9 100644 --- a/packages/config/src/auth/providers.ts +++ b/packages/config/src/auth/providers.ts @@ -148,6 +148,10 @@ export const external = Schema.Struct({ id: "facebook", name: "Facebook", }), + figma: provider({ + id: "figma", + name: "Figma", + }), github: provider({ id: "github", name: "GitHub", diff --git a/packages/config/src/auth/sms.ts b/packages/config/src/auth/sms.ts index 05b1570139..ac1e5f3212 100644 --- a/packages/config/src/auth/sms.ts +++ b/packages/config/src/auth/sms.ts @@ -19,6 +19,8 @@ const defaultEnableSignup = false; const defaultEnableConfirmations = false; const defaultTemplate = "Your code is {{ .Code }}"; const defaultMaxFrequency = "5s"; +const defaultOtpLength = 6; +const defaultOtpExpiry = 60; const defaultTwilio = {}; const defaultTwilioEnabled = false; const defaultTwilioAccountSid = ""; @@ -168,6 +170,18 @@ export const sms = Schema.Struct({ tags, links: [links.auth], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxFrequency))), + otp_length: Schema.Number.annotate({ + default: defaultOtpLength, + description: "Number of characters used in the SMS OTP.", + tags, + links: [links.auth], + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultOtpLength))), + otp_expiry: Schema.Number.annotate({ + default: defaultOtpExpiry, + description: "Number of seconds before the SMS OTP expires.", + tags, + links: [links.auth], + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultOtpExpiry))), twilio: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultTwilioEnabled, @@ -187,6 +201,13 @@ export const sms = Schema.Struct({ tags, links: [links.phoneLogin("Twilio")], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultTwilioMessageServiceSid))), + content_sid: Schema.optionalKey( + Schema.String.annotate({ + description: "The content SID of a WhatsApp/Messaging Content Template for the Twilio API.", + tags, + links: [links.phoneLogin("Twilio")], + }), + ), auth_token: Schema.optionalKey( secret({ examples: ["env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"], diff --git a/packages/config/src/config-diff.unit.test.ts b/packages/config/src/config-diff.unit.test.ts index e933bdc1c5..375528b6af 100644 --- a/packages/config/src/config-diff.unit.test.ts +++ b/packages/config/src/config-diff.unit.test.ts @@ -408,6 +408,60 @@ describe("diffProjectConfig classification", () => { expect(result.unmanaged).toEqual([]); }); + test("db.major_version and db.pooler.* classify as normal update/remote_only, never unmanaged (PR #6451 correction)", () => { + // Both are `comparableProjectConfigPaths` members (`fromApiProjectConfig` + // maps them from real, `v2GetProjectConfig`-reported state) AND + // `fromConfigDocument` populates both normally (CLI-2316's package review + // round). The ORIGINAL version of this test asserted the opposite — + // excluding them from the document arm entirely, which made them + // permanently `unmanaged` for every stock project (the `supabase init` + // template declares all of these), silently blocking `config pull` from + // ever syncing the platform's real Postgres version or pooler settings: + // `unmanaged` paths never reach `changes` (`hasAncestorPathKey` above), + // and `config pull`'s planner (`legacyPlanConfigPull`) only ever writes + // from `changes`. `config push` itself doesn't consult `ProjectConfig` at + // all today (still the legacy v1 `config-sync` mappers), so there was + // never a push-correctness reason to exclude them either. + const result = diffWith( + { db: { major_version: 15, pooler: { pool_mode: "session", default_pool_size: 15 } } }, + { + database: { major_version: 17 }, + pooler: { pool_mode: "transaction", default_pool_size: 20 }, + }, + ); + expect(changeAt(result.changes, ["db", "major_version"])).toMatchObject({ + class: "update", + local: 15, + remote: 17, + }); + expect(changeAt(result.changes, ["db", "pooler", "pool_mode"])).toMatchObject({ + class: "update", + local: "session", + remote: "transaction", + }); + expect(changeAt(result.changes, ["db", "pooler", "default_pool_size"])).toMatchObject({ + class: "update", + local: 15, + remote: 20, + }); + expect(result.unmanaged).toEqual([]); + }); + + test("db.pooler.enabled and db.pooler.port are never comparable — v2GetProjectConfig reports neither", () => { + // Unlike their 3 siblings above, these 2 have no registry row at all + // (`isComparableProjectConfigPath` is false for both), so they never + // reach the comparison loop regardless of local/remote presence — there + // is no remote value to ever compare or pull for either. + const result = diffWith( + { db: { pooler: { enabled: false, port: 12345 } } }, + { pooler: { pool_mode: "session" } }, + ); + expect(changeAt(result.changes, ["db", "pooler", "enabled"])).toBeUndefined(); + expect(changeAt(result.changes, ["db", "pooler", "port"])).toBeUndefined(); + expect(result.unmanaged).not.toContainEqual(["db", "pooler", "enabled"]); + expect(result.unmanaged).not.toContainEqual(["db", "pooler", "port"]); + }); + test("sequence arrays register reordering as drift", () => { // api.schemas is order-significant (the first entry is PostgREST's // default schema), so local ["public","extensions"] vs the wire's diff --git a/packages/config/src/project-config/project-config.ts b/packages/config/src/project-config/project-config.ts index 3529bb957a..4ee1ab660f 100644 --- a/packages/config/src/project-config/project-config.ts +++ b/packages/config/src/project-config/project-config.ts @@ -119,6 +119,25 @@ export type ReadonlyJsonValue = * see that ADR for the full enumeration). A `ProjectConfig` built from a * document is therefore not a faithful rendering of what the user wrote in * their config file; see {@link fromConfigDocument}'s own docstring. + * + * Relatedly (CLI-2316), a document-sourced `ProjectConfig` never carries any + * of the paths in {@link DOCUMENT_ONLY_LOCAL_PATHS} — ports, TLS/URL + * overrides, `db.pooler.{enabled,port}`, the whole `db.migrations`/`db.seed` + * subtrees, every config-side `realtime.*` field, and most of + * `experimental.*` — even though each lives inside one of the seven hosted + * sections above: none has any hosted counterpart on either arm — confirmed + * directly against the `v2GetProjectConfig` OpenAPI-generated schema, not + * assumed. `db.major_version` and `db.pooler`'s other 3 fields + * (`pool_mode`/`default_pool_size`/`max_client_conn`) are deliberately NOT in + * that list, despite genuinely having no `config push` write path either: + * both `config diff` and `config pull` — `ProjectConfig`'s actual current + * consumers — need them to stay normally comparable so `config pull` can + * still sync the platform's real (read-only-via-push) Postgres version and + * pooler settings into the file; only `config push` itself (which doesn't + * consult `ProjectConfig` at all today — it still runs on the legacy v1 + * `config-sync` mappers) would ever need to know these are unpushable, and + * that distinction belongs to CLI-2313/CLI-2314's push rework, not to this + * list. */ export type ProjectConfig = DeepPartial> & { // Readonly, recursively: the runtime value is deep-frozen @@ -130,8 +149,17 @@ export type ProjectConfig = DeepPartial> & { /** * Deep-copies `value` (a hosted-section subtree rooted at `path`) at the - * OBJECT level, dropping every leaf whose full path matches an `x-secret` - * schema annotation (CLI-2230's secret-omission finding): `fromConfigDocument`'s + * OBJECT level for the DOCUMENT arm specifically — named for that arm (PR + * #6451 review round; the prior name, `copyHostedValueWithoutSecrets`, only + * described its original single responsibility and this function has never + * had a second caller: `fromApiProjectConfig` never shares it, since the API + * arm's own secret handling is the `isSecret`-row branch in + * `applyMappingRows` below, an entirely different mechanism over a different + * input shape) — dropping every leaf whose full path matches an `x-secret` + * schema annotation (CLI-2230's secret-omission finding) OR is a member of + * {@link DOCUMENT_ONLY_LOCAL_PATHS} (CLI-2316 — a field that lives inside a + * hosted section but has no live hosted counterpart on either arm): + * `fromConfigDocument`'s * input is a *decoded* `CliConfig`/`EffectiveConfig`, where `secret()`-annotated fields * (`../lib/env.ts`) hold plaintext or an unresolved `env(VAR)` literal, never * a `Redacted` wrapper (decode never redacts — only @@ -143,10 +171,15 @@ export type ProjectConfig = DeepPartial> & { * HMAC digest), a document-sourced `ProjectConfig` that kept its secrets * would register as drift against the API-sourced side for every secret * field, which is worse than useless for a diff consumer. Arrays are copied - * recursively, element by element — a hosted array can hold objects (e.g. - * `experimental.inspect.rules`), and a merely-sliced container would alias - * them back to the (possibly frozen) input, breaking the fresh-copy - * contract. No `x-secret` leaf in `CliConfigSchema` sits inside an array, so + * recursively, element by element — no schema validation runs on this + * function's input, so a caller can still hand it an array of objects at any + * path (`experimental.inspect.rules` was the one schema field shaped that + * way, until CLI-2316 excluded the whole `experimental.inspect` subtree as + * CLI-only — this branch stays defensive against object-shaped array + * elements arriving through any future field or a loosely-typed caller), and + * a merely-sliced container would alias them back to the (possibly frozen) + * input, breaking the fresh-copy contract. No `x-secret` leaf in + * `CliConfigSchema` sits inside an array, so * the secret-path walk carries through elements as a no-op; empty-record * *elements* are preserved (the empty-container prune applies only to record * children — arrays compare wholesale in `../sparse.ts`, so their contents @@ -166,26 +199,27 @@ export type ProjectConfig = DeepPartial> & { * explicit value (e.g. "no redirect URLs"), never litter from secret * stripping. */ -function copyHostedValueWithoutSecrets(value: unknown, path: ReadonlyArray): unknown { +function copyHostedValueForDocument(value: unknown, path: ReadonlyArray): unknown { if (Array.isArray(value)) { - // Elements are copied recursively too — a hosted array can hold objects - // (e.g. `experimental.inspect.rules`), and a merely-sliced container + // Elements are copied recursively too — this function's input is never + // schema-validated, so an object-shaped array element is still reachable + // (see this function's own docstring), and a merely-sliced container // would alias them back to the (possibly frozen) input, breaking the // fresh-copy contract. The path passes through unchanged: no x-secret // pattern descends through an array in the hosted schema today, and // empty-record *elements* are preserved (the empty-container prune below // applies only to record children — arrays compare wholesale, so their // contents must survive verbatim). - return value.map((element) => copyHostedValueWithoutSecrets(element, path)); + return value.map((element) => copyHostedValueForDocument(element, path)); } if (isObject(value)) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { const childPath = [...path, key]; - if (isSecretPath(childPath)) { + if (isSecretPath(childPath) || isDocumentOnlyLocalPath(childPath)) { continue; } - const copied = copyHostedValueWithoutSecrets(child, childPath); + const copied = copyHostedValueForDocument(child, childPath); // Prune only containers this copy itself EMPTIED (a secret-stripped // subtree, possibly cascading upward) — never one that was empty in the // input. An originally-empty object can be data: a record entry's value @@ -207,6 +241,156 @@ function copyHostedValueWithoutSecrets(value: unknown, path: ReadonlyArray> = [ + ["api", "port"], + ["api", "tls"], + ["api", "external_url"], + ["db", "port"], + ["db", "shadow_port"], + ["db", "health_timeout"], + ["db", "pooler", "enabled"], + ["db", "pooler", "port"], + ["db", "migrations"], + ["db", "seed"], + ["realtime", "enabled"], + ["realtime", "ip_version"], + ["realtime", "max_header_length"], + ["experimental", "orioledb_version"], + ["experimental", "s3_host"], + ["experimental", "s3_region"], + ["experimental", "pgdelta"], + ["experimental", "inspect"], +]; + +function isDocumentOnlyLocalPath(path: ReadonlyArray): boolean { + return DOCUMENT_ONLY_LOCAL_PATHS.some( + (excluded) => + excluded.length === path.length && + excluded.every((segment, index) => segment === path[index]), + ); +} + /** * Applies every registry row's `normalizeDocument` (`./registry-row.ts`) to * `output` in place, at `row.configPath`, after the secret-omitting copy @@ -367,9 +551,13 @@ function unwrapConfigDocumentSource(input: Record): { * Projects a {@link CliConfig} document (or any {@link EffectiveConfig} * operand — a full `CliConfig` is one) down to its hosted-section subset. * Copies each hosted section deeply and only when own-present on `config`, - * omitting every `x-secret` leaf ({@link copyHostedValueWithoutSecrets}) and - * canonicalizing every field a registry row's `normalizeDocument` covers - * ({@link applyDocumentNormalizations}) — parity with + * omitting every `x-secret` leaf and every {@link DOCUMENT_ONLY_LOCAL_PATHS} + * entry — a field with no live hosted counterpart on either arm, e.g. + * `db.port`, `db.pooler.{enabled,port}`, `db.migrations`, `db.seed`, the + * whole `realtime` section (both via {@link copyHostedValueForDocument}; + * CLI-2316) — and canonicalizing every field a registry row's + * `normalizeDocument` covers ({@link applyDocumentNormalizations}) — parity + * with * {@link fromApiProjectConfig}'s own secret omission and canonical * duration/byte-size spellings, so the same logical hosted config compares * equal regardless of which side produced it, and so this function never @@ -470,7 +658,7 @@ export function fromConfigDocument(input: unknown): unknown { let copied: unknown; try { section = config[key]; - copied = copyHostedValueWithoutSecrets(section, [key]); + copied = copyHostedValueForDocument(section, [key]); } catch (cause) { if (cause instanceof ProjectConfigParseError) { throw cause; @@ -481,7 +669,7 @@ export function fromConfigDocument(input: unknown): unknown { reason: "caller_misuse", }); } - // Same emptied-by-the-copy prune as `copyHostedValueWithoutSecrets`'s + // Same emptied-by-the-copy prune as `copyHostedValueForDocument`'s // own recursion, applied at the section boundary: a section that turns // out to contain nothing but secrets must disappear from the projection // entirely, while a section the document genuinely declared empty 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 2bb1d4465b..a87cc5bfbe 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -9,6 +9,7 @@ import { Schema } from "effect"; import { attachApiResponse, comparableProjectConfigPaths, + DOCUMENT_ONLY_LOCAL_PATHS, fromApiProjectConfig, fromConfigDocument, isComparableProjectConfigPath, @@ -158,13 +159,17 @@ describe("fromConfigDocument", () => { // itself EMPTIED (secret stripping) — an originally-empty container is // declared data (a record entry's value can be an empty struct by schema // design, e.g. `storage.analytics.buckets` entries, where the key is the - // information). + // information). `realtime` is absent, not present-as-`{}` like `workers`: + // all 3 of its config-side fields are `DOCUMENT_ONLY_LOCAL_PATHS` entries + // (CLI-2316), all 3 are always-materialized (not `optionalKey`) so the + // default config always declares them, and the emptied-by-exclusion + // section prune (same rule as the secret-stripped case) removes it + // entirely — see the dedicated describe block below. expect(Object.keys(projected).sort()).toEqual([ "api", "auth", "db", "experimental", - "realtime", "storage", "workers", ]); @@ -207,8 +212,18 @@ describe("fromConfigDocument", () => { test("deep-copies rather than sharing the subtree reference", () => { const config = getDefaultCliConfig(); const projected = fromConfigDocument(config); + // The top-level `api`/`db` containers are still freshly built (never the + // same object as `config`'s), even though `toEqual`-comparing them WHOLE + // against `config.api`/`config.db` would now fail: CLI-2316 strips + // several of their fields (`api.port`/`tls`/`external_url`, + // `db.port`/`shadow_port`/`health_timeout`/`major_version`/`pooler`/ + // `migrations`/`seed` — see the dedicated describe block below) from the + // projection. `storage.s3_protocol` — an always-materialized nested + // object none of those exclusions touch — is the equality probe instead. expect(projected.api).not.toBe(config.api); - expect(projected.api).toEqual(config.api); + expect(projected.db).not.toBe(config.db); + expect(projected.storage?.s3_protocol).not.toBe(config.storage.s3_protocol); + expect(projected.storage?.s3_protocol).toEqual(config.storage.s3_protocol); expect(projected.auth?.captcha).not.toBe(config.auth.captcha); expect(projected.auth?.captcha).toEqual(config.auth.captcha); }); @@ -449,6 +464,253 @@ describe("fromConfigDocument", () => { }); }); +describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { + function readAtPath(root: unknown, path: ReadonlyArray): unknown { + let current = root; + for (const segment of path) { + if (current === null || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; + } + + test("excludes local ports, db.pooler.{enabled,port}, and the whole db.migrations/seed subtrees", () => { + const document = decodeCliConfig({ + api: { port: 9999, external_url: "http://example.com", max_rows: 42 }, + db: { + port: 9999, + shadow_port: 8888, + health_timeout: "5m", + pooler: { + enabled: true, + port: 7777, + pool_mode: "session", + default_pool_size: 5, + max_client_conn: 50, + }, + migrations: { enabled: false, schema_paths: ["a.sql"] }, + seed: { enabled: false, sql_paths: ["b.sql"] }, + settings: { max_connections: 5 }, + }, + }); + + const projected = fromConfigDocument(document); + + expect(Object.hasOwn(projected.api ?? {}, "port")).toBe(false); + expect(Object.hasOwn(projected.api ?? {}, "external_url")).toBe(false); + expect(Object.hasOwn(projected.api ?? {}, "tls")).toBe(false); + expect(Object.hasOwn(projected.db ?? {}, "port")).toBe(false); + expect(Object.hasOwn(projected.db ?? {}, "shadow_port")).toBe(false); + expect(Object.hasOwn(projected.db ?? {}, "health_timeout")).toBe(false); + expect(Object.hasOwn(projected.db?.pooler ?? {}, "enabled")).toBe(false); + expect(Object.hasOwn(projected.db?.pooler ?? {}, "port")).toBe(false); + expect(Object.hasOwn(projected.db ?? {}, "migrations")).toBe(false); + expect(Object.hasOwn(projected.db ?? {}, "seed")).toBe(false); + // Siblings prove the exclusion is targeted, not a section-wide wipe — + // `pool_mode`/`default_pool_size`/`max_client_conn` are real, + // `v2GetProjectConfig`-reported hosted facts (PR #6451 review round) and + // must stay comparable for `config diff`/`config pull`, unlike `enabled`/ + // `port` right above, which the API never reports at all. + expect(projected.api?.max_rows).toBe(42); + expect(projected.db?.settings?.max_connections).toBe(5); + expect(projected.db?.pooler).toEqual({ + pool_mode: "session", + default_pool_size: 5, + max_client_conn: 50, + }); + }); + + test("excludes the whole realtime section — it survives on neither arm", () => { + const document = decodeCliConfig({ + realtime: { enabled: false, ip_version: "IPv6", max_header_length: 1 }, + }); + const projected = fromConfigDocument(document); + // Every config-side `realtime` field is excluded, so — unlike `workers`, + // which survives as `{}` — the section disappears entirely: it was + // emptied BY this exclusion, the same prune rule as a secret-stripped + // section. + expect(Object.hasOwn(projected, "realtime")).toBe(false); + }); + + test("excludes local-only experimental fields while experimental.webhooks (genuinely pushed) survives", () => { + const document = decodeCliConfig({ + experimental: { + orioledb_version: "1.0", + s3_host: "bucket.s3.example.com", + s3_region: "us-east-1", + pgdelta: { enabled: true }, + inspect: { rules: [{ name: "r1" }] }, + webhooks: { enabled: true }, + }, + }); + const projected = fromConfigDocument(document); + expect(Object.hasOwn(projected.experimental ?? {}, "orioledb_version")).toBe(false); + expect(Object.hasOwn(projected.experimental ?? {}, "s3_host")).toBe(false); + expect(Object.hasOwn(projected.experimental ?? {}, "s3_region")).toBe(false); + expect(Object.hasOwn(projected.experimental ?? {}, "pgdelta")).toBe(false); + expect(Object.hasOwn(projected.experimental ?? {}, "inspect")).toBe(false); + expect(projected.experimental?.webhooks).toEqual({ enabled: true }); + }); + + test("db.major_version and db.pooler.{pool_mode,default_pool_size,max_client_conn} populate on BOTH arms (PR #6451 correction)", () => { + // These 4 fields are real, `v2GetProjectConfig`-reported hosted facts + // with no `config push` write path — but `ProjectConfig`'s actual + // current consumers are `config diff`/`config pull` (`v2GetProjectConfig`), + // not `config push` (still the legacy v1 `config-sync` mappers, with zero + // `ProjectConfig` involvement). Excluding them from the document arm — + // this test's ORIGINAL, incorrect assertion — made them permanently + // `unmanaged` for every stock project (the `supabase init` template + // declares all four), which blocked `config pull` from ever syncing the + // platform's real values down. They must stay symmetric across both arms. + const documentSide = fromConfigDocument( + decodeCliConfig({ db: { major_version: 15, pooler: { pool_mode: "session" } } }), + ); + expect(documentSide.db?.major_version).toBe(15); + // `default_pool_size`/`max_client_conn` are schema-decoded defaults here + // (20/100, `../db.ts`) — present because the whole `pooler` struct + // materializes on decode, not because this document declared them. + // `enabled`/`port` are excluded regardless (the next test covers that). + expect(documentSide.db?.pooler).toEqual({ + pool_mode: "session", + default_pool_size: 20, + max_client_conn: 100, + }); + + const apiSide = fromApiProjectConfig({ + database: { major_version: 17 }, + pooler: { pool_mode: "session", default_pool_size: 15, max_client_conn: 200 }, + }); + expect(apiSide.db?.major_version).toBe(17); + expect(apiSide.db?.pooler).toEqual({ + pool_mode: "session", + default_pool_size: 15, + max_client_conn: 200, + }); + }); + + test("db.pooler.enabled and db.pooler.port are absent from BOTH arms — v2GetProjectConfig reports neither", () => { + const documentSide = fromConfigDocument( + decodeCliConfig({ db: { pooler: { enabled: true, port: 54329 } } }), + ); + // `pooler` itself survives (its other 3 fields decode to their schema + // defaults) — only `enabled`/`port` are excluded from it. + expect(Object.hasOwn(documentSide.db?.pooler ?? {}, "enabled")).toBe(false); + expect(Object.hasOwn(documentSide.db?.pooler ?? {}, "port")).toBe(false); + expect(documentSide.db?.pooler?.pool_mode).toBe("transaction"); + + // The API arm never populates these either: no registry row maps them, + // since `v2GetProjectConfig`'s `pooler` struct has no `enabled`/`port` + // field to map from in the first place. + const apiSide = fromApiProjectConfig({ + pooler: { enabled: true, port: 54329, pool_mode: "session" }, + }); + expect(Object.hasOwn(apiSide.db?.pooler ?? {}, "enabled")).toBe(false); + expect(Object.hasOwn(apiSide.db?.pooler ?? {}, "port")).toBe(false); + expect(apiSide.db?.pooler).toEqual({ pool_mode: "session" }); + }); + + // Exhaustive counterpart to the hand-picked tests above, iterating + // `DOCUMENT_ONLY_LOCAL_PATHS` itself rather than a second hand-picked field + // list. Unlike the x-secret exhaustiveness test just above — which builds + // its OWN probe programmatically from `secretPathPatterns`, so it can never + // go vacuous — this probe is still hand-written (`DOCUMENT_ONLY_LOCAL_PATHS` + // mixes whole-subtree and scalar-leaf entries of different value types, so + // one generic "put a marker at every path" builder can't populate it the + // way the all-string x-secret patterns allow). The `toBeDefined` check + // below on `document` is a PARTIAL guard, not a complete one (PR #6451 + // review round): it always catches a typo'd or renamed path in the + // CONSTANT itself (`decodeCliConfig` only ever produces the schema's own + // spelling, never a typo'd one, so `readAtPath` finds nothing regardless of + // defaults). What it does NOT reliably catch is the probe below simply + // forgetting to set a value at a CORRECTLY-spelled listed path: 12 of + // these 18 paths are always-materialized (`withDecodingDefaultKey`, never + // `optionalKey`), so `decodeCliConfig` fills a schema default for them even + // when the probe omits an explicit value — `toBeDefined` passes on that + // default either way. Only the other 6 — `api.external_url` and the 5 + // `experimental.*` entries, every one `optionalKey` — stay genuinely + // undefined unless the probe sets them explicitly, so only THOSE 6 catch a + // probe that forgot to populate a listed path. + test("no DOCUMENT_ONLY_LOCAL_PATHS entry survives fromConfigDocument, exhaustively", () => { + expect(DOCUMENT_ONLY_LOCAL_PATHS.length).toBeGreaterThan(0); + + const document = decodeCliConfig({ + api: { + port: 1, + external_url: "http://example.com", + tls: { enabled: true }, + max_rows: 42, + }, + db: { + port: 1, + shadow_port: 2, + health_timeout: "5m", + major_version: 15, + pooler: { enabled: true, port: 7777, pool_mode: "session" }, + migrations: { enabled: false }, + seed: { enabled: false }, + settings: { max_connections: 5 }, + }, + realtime: { enabled: false, ip_version: "IPv6", max_header_length: 1 }, + experimental: { + orioledb_version: "1.0", + s3_host: "host", + s3_region: "region", + pgdelta: { enabled: true }, + inspect: { rules: [{ name: "r1" }] }, + webhooks: { enabled: true }, + }, + }); + + const projected = fromConfigDocument(document); + + for (const path of DOCUMENT_ONLY_LOCAL_PATHS) { + // The probe actually populated this path — otherwise the assertion + // below would pass whether or not the exclusion code does anything. + expect(readAtPath(document, path)).toBeDefined(); + expect(readAtPath(projected, path)).toBeUndefined(); + } + + expect(projected.api?.max_rows).toBe(42); + expect(projected.db?.settings?.max_connections).toBe(5); + expect(projected.experimental?.webhooks).toEqual({ enabled: true }); + // `major_version`/`pool_mode` are declared right alongside the excluded + // `pooler.enabled`/`pooler.port` above — proving the exclusion is + // per-field, not a `db.pooler`- or `db`-wide wipe (PR #6451 correction). + expect(projected.db?.major_version).toBe(15); + expect(projected.db?.pooler?.pool_mode).toBe("session"); + }); + + // Integrity guard (PR #6451 review round): the bug this whole file's + // review round caught was exactly this overlap — `db.major_version` and 3 + // of `db.pooler`'s fields were BOTH `comparableProjectConfigPaths` members + // (real registry rows, `./registry.ts`) AND `DOCUMENT_ONLY_LOCAL_PATHS` + // entries, which permanently blocked `config diff`/`config pull` from ever + // comparing or pulling them (see the corrected tests above). This test + // pins the invariant going forward: no comparable (registry-mapped) path, + // nor any of its ancestors, may ever be a `DOCUMENT_ONLY_LOCAL_PATHS` + // member — a future registry row added beneath an excluded prefix (e.g. + // under `db.migrations`, which today has none) would silently become + // uncomparable exactly like `major_version`/`pooler` did, and this test + // would catch it the moment that row is added. + test("no comparableProjectConfigPaths entry (or its ancestors) is ever a DOCUMENT_ONLY_LOCAL_PATHS member", () => { + expect(comparableProjectConfigPaths.length).toBeGreaterThan(0); + + function isExcludedOrAncestorExcluded(path: ReadonlyArray): boolean { + return DOCUMENT_ONLY_LOCAL_PATHS.some((excluded) => { + if (excluded.length > path.length) { + return false; + } + return excluded.every((segment, index) => segment === path[index]); + }); + } + + const violations = comparableProjectConfigPaths.filter(isExcludedOrAncestorExcluded); + expect(violations).toEqual([]); + }); +}); + describe("fromApiProjectConfig — envelope unwrapping", () => { test("the full envelope, bare data object, and bare attributes all produce equal results", () => { const attributes = { api: { max_rows: 5 } }; @@ -965,6 +1227,97 @@ describe("fromApiProjectConfig — auth section", () => { const result = fromApiProjectConfig({ auth: { sms_test_otp: "15551234567=123456" } }); expect(result.auth?.sms?.test_otp).toEqual({ "15551234567": "123456" }); }); + + // CLI-2316 follow-up: figma is now a real provider (../auth/providers.ts), + // mirroring github's shape (no url, has email_optional, no skip_nonce_check + // — verified against the real V1GetAuthServiceConfigOutput contract, which + // has no `external_figma_skip_nonce_check` field at all). + test("figma maps like any other non-apple/google provider", () => { + const apiSide = fromApiProjectConfig({ + auth: { + external_figma_enabled: true, + external_figma_client_id: "figma-id", + external_figma_secret: "figma-secret", + external_figma_email_optional: true, + }, + }); + expect(apiSide.auth?.external?.figma).toEqual({ + enabled: true, + client_id: "figma-id", + email_optional: true, + }); + + const documentSide = fromConfigDocument( + decodeCliConfig({ + auth: { + external: { + figma: { enabled: true, client_id: "figma-id", secret: "figma-secret" }, + }, + }, + }), + ); + // The document arm copies the whole decoded provider struct (minus the + // secret leaf) verbatim — every schema-materialized field, not just the + // two declared above. + expect(documentSide.auth?.external?.figma).toEqual({ + enabled: true, + client_id: "figma-id", + url: "", + redirect_uri: "", + skip_nonce_check: false, + email_optional: false, + }); + }); + + // CLI-2316 follow-up: `sms.otp_length`/`sms.otp_expiry` are new config-schema + // fields for pre-existing real GoTrue fields (`sms_otp_length`/`sms_otp_exp`) + // that the legacy shell's config-sync never modeled and neither did Go's own + // `sms` struct — not a Go-parity gap, a genuinely new mapping. + test("sms_otp_length/sms_otp_exp map to auth.sms.otp_length/otp_expiry", () => { + const apiSide = fromApiProjectConfig({ auth: { sms_otp_length: 6, sms_otp_exp: 60 } }); + expect(apiSide.auth?.sms?.otp_length).toBe(6); + expect(apiSide.auth?.sms?.otp_expiry).toBe(60); + + const documentSide = fromConfigDocument( + decodeCliConfig({ auth: { sms: { otp_length: 8, otp_expiry: 120 } } }), + ); + expect(documentSide.auth?.sms?.otp_length).toBe(8); + expect(documentSide.auth?.sms?.otp_expiry).toBe(120); + }); + + // CLI-2316 follow-up: `sms.twilio.content_sid` is a new config-schema field + // for a pre-existing real GoTrue field (`sms_twilio_content_sid`), + // Twilio-only (no `sms_twilio_verify_content_sid` API counterpart) — same + // "omitted when the SMS provider is explicitly unset" gating as + // `account_sid`/`message_service_sid` (`smsCredentialStringRow`). + test("sms_twilio_content_sid maps to auth.sms.twilio.content_sid, gated the same as its siblings", () => { + const apiSide = fromApiProjectConfig({ + auth: { sms_provider: "twilio", sms_twilio_content_sid: "HXreal00000000000000000000000000" }, + }); + expect(apiSide.auth?.sms?.twilio?.content_sid).toBe("HXreal00000000000000000000000000"); + + const explicitlyUnset = fromApiProjectConfig({ + auth: { sms_provider: "", sms_twilio_content_sid: "HXreal00000000000000000000000000" }, + }); + expect(Object.hasOwn(explicitlyUnset.auth?.sms?.twilio ?? {}, "content_sid")).toBe(false); + + const documentSide = fromConfigDocument( + decodeCliConfig({ + auth: { + sms: { + twilio: { + enabled: true, + account_sid: "AC1", + message_service_sid: "MG1", + content_sid: "HX1", + auth_token: "at1", + }, + }, + }, + }), + ); + expect(documentSide.auth?.sms?.twilio?.content_sid).toBe("HX1"); + }); }); describe("fromApiProjectConfig — secrets (ADR 0019 rule 5)", () => { @@ -1389,11 +1742,25 @@ describe("review round: numeric and provider narrowing (CLI-2230)", () => { describe("review round: aliasing, unknown-empty sections, path encoding (CLI-2230)", () => { test("object elements inside hosted arrays are copied, not aliased", () => { - const rule = { name: "r1" }; - const projected = fromConfigDocument({ experimental: { inspect: { rules: [rule] } } }); - const copied = projected.experimental?.inspect?.rules?.[0]; - expect(copied).toEqual(rule); - expect(copied).not.toBe(rule); + // `experimental.inspect.rules` was the only schema field shaped as an + // array of objects (this file's own `copyHostedValueWithoutSecrets` + // docstring used it as its example) — CLI-2316 excludes the whole + // `experimental.inspect` subtree as CLI-only, so it can no longer probe + // this. But `fromConfigDocument` runs no schema validation on its input + // (see that same docstring), so the object-in-array copy path is still + // reachable through any surviving array field — `api.schemas` (real + // schema type `string[]`) is used here as a structurally-typed carrier: a + // `Record` operand is assignable to the exported + // `EffectiveConfig` parameter (every `EffectiveConfig` property is + // optional, so nothing named on it needs to reconcile against the + // index-signature type), with no cast, while still reaching this + // function's fully untyped runtime behavior. + const element = { nested: "value" }; + const probe: Record = { api: { schemas: [element] } }; + const projected = fromConfigDocument(probe); + const copied = projected.api?.schemas?.[0]; + expect(copied).toEqual(element); + expect(copied).not.toBe(element); }); test("an unknown empty section survives into unmappedApiFields", () => { diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index 5062935916..eb11221e6e 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -1070,6 +1070,18 @@ const smsBaseRows: ReadonlyArray = [ boolRow(["auth", "sms", "enable_confirmations"], "sms_autoconfirm"), stringRow(["auth", "sms", "template"], "sms_template"), secondsDurationRow(["auth", "sms", "max_frequency"], "sms_max_frequency"), + // No sync precedent (CLI-2316 follow-up audit): `sms.otp_length`/ + // `otp_expiry` are new config-schema fields (`../auth/sms.ts`) added + // alongside this pair of rows — the legacy shell's `config-sync/auth.sync.ts` + // predates both and never read or wrote either, matching neither Go's own + // `sms` struct (`apps/cli-go/pkg/config/auth.go`, which also has no + // `OtpLength`/`OtpExpiry` on `sms` — only on the unrelated `email` and + // `mfa.phone` structs) — this is a genuinely new config surface for a + // pre-existing, real GoTrue field pair (`sms_otp_length`/`sms_otp_exp`, + // confirmed live via `apps/cli-e2e/fixtures/recorded/ + // GET_v1_projects___PROJECT_REF___config_auth`), not a Go-parity gap. + uintRow(["auth", "sms", "otp_length"], "sms_otp_length"), + uintRow(["auth", "sms", "otp_expiry"], "sms_otp_exp"), { // auth.sync.ts:1679, 1736-1747 (envToMap). Null/empty/unparsed → omit; // a present non-string is a malformed platform response and throws like @@ -1179,6 +1191,12 @@ const smsCredentialRows: ReadonlyArray = [ ["auth", "sms", "twilio", "message_service_sid"], "sms_twilio_message_service_sid", ), + // No sync precedent (CLI-2316 follow-up audit): `twilio.content_sid` is a + // new config-schema field (`../auth/sms.ts`) for a pre-existing, real + // GoTrue field (`sms_twilio_content_sid`, confirmed live via + // `apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth`). + // Twilio-only: the API has no `sms_twilio_verify_content_sid` counterpart. + smsCredentialStringRow(["auth", "sms", "twilio", "content_sid"], "sms_twilio_content_sid"), secretRow(["auth", "sms", "twilio", "auth_token"], "sms_twilio_auth_token"), smsCredentialStringRow( ["auth", "sms", "twilio_verify", "account_sid"], @@ -1227,10 +1245,6 @@ const hookRows: ReadonlyArray = AUTH_HOOK_NAMES.flatMap // availability taken from ../auth/providers.ts and RemoteAuthConfig) // // Corrections against the mined field list: -// - "figma" is a case in auth.sync.ts's remote-field switches -// (getProviderEnabled et al., :1813-1814 and siblings) but -// ../auth/providers.ts's `external` struct has no `figma` member, so no -// row is emitted for it — the config schema cannot represent it. // - `url` only exists as an API field for azure/gitlab/keycloak/workos // (getProviderUrl, :1942-1955), even though the schema's `provider()` // struct declares a `url` field (with a default) for every provider. @@ -1238,6 +1252,25 @@ const hookRows: ReadonlyArray = AUTH_HOOK_NAMES.flatMap // both RemoteAuthConfig (:471-474) and getProviderEmailOptional's switch // (:1957-1998) — even though every other provider (including apple and // google) has one. +// - `skip_nonce_check` has no API field for ANY provider except google +// (`googleSkipNonceCheckRow`, below) — verified against the generated +// contract's full `V1GetAuthServiceConfigOutput` field list (CLI-2316 +// follow-up audit), even though the schema's `provider()` struct declares +// a `skip_nonce_check` field for every provider. +// - "figma" IS representable now (`../auth/providers.ts`, CLI-2316 follow-up +// — was previously documented here as unrepresentable, since the schema +// had no `figma` member at all). +// - plain "slack" (as opposed to `slack_oidc`) has a real API field set +// (`external_slack_client_id`/`_enabled`/`_secret`/`_email_optional`) but +// is DELIBERATELY not a schema member and never will be without a +// separate decision to reverse it: `../auth/providers.ts`'s own comment +// records that Go's deprecated `linkedin`/`slack` provider ids are +// intentionally unmodeled, matching `(e external) validate()`'s +// unconditional deletion of those keys before decode — `../io.ts`'s +// `normalizeDeprecatedExternalProviders` strips a config's `[auth. +// external.slack]` table (warning on stderr when it was `enabled`) before +// this schema ever sees it. A real, live `external_slack_*` API surface +// existing does not by itself justify undoing that Go-parity deprecation. interface ExternalProviderSpec { readonly id: string; @@ -1251,6 +1284,7 @@ const EXTERNAL_PROVIDERS: ReadonlyArray = [ { id: "bitbucket", hasUrl: false, hasEmailOptional: true }, { id: "discord", hasUrl: false, hasEmailOptional: true }, { id: "facebook", hasUrl: false, hasEmailOptional: true }, + { id: "figma", hasUrl: false, hasEmailOptional: true }, { id: "github", hasUrl: false, hasEmailOptional: true }, { id: "gitlab", hasUrl: true, hasEmailOptional: true }, { id: "google", hasUrl: false, hasEmailOptional: true }, diff --git a/packages/config/src/project-config/registry-integrity.unit.test.ts b/packages/config/src/project-config/registry-integrity.unit.test.ts index 8ff3d65495..984f9454d3 100644 --- a/packages/config/src/project-config/registry-integrity.unit.test.ts +++ b/packages/config/src/project-config/registry-integrity.unit.test.ts @@ -15,7 +15,7 @@ import { projectConfigMappingRows } from "./registry.ts"; * resolve against {@link CliConfigSchema}'s AST, and every row's `apiPath` * (plus `alsoConsumes` and `./registry-auth.ts`'s `unmappedSecretApiPaths`) * must resolve against {@link ProjectConfigApiAttributesSchema}'s AST. This - * is what keeps the 233 rows across `./registry.ts`/`./registry-auth.ts` + * is what keeps the 243 rows across `./registry.ts`/`./registry-auth.ts` * true when either schema moves — a renamed or removed field fails a test * here instead of silently producing a `ProjectConfig` that never populates * (a wrong `configPath`) or a row that never reads a real API field (a