Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
26 changes: 26 additions & 0 deletions docs/adr/0021-projectconfig-convergence-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/config/src/auth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions packages/config/src/auth/sms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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,
Expand All @@ -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)"],
Expand Down
54 changes: 54 additions & 0 deletions packages/config/src/config-diff.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading