diff --git a/.changeset/auth-config-stop-advertising-reserved-features.md b/.changeset/auth-config-stop-advertising-reserved-features.md new file mode 100644 index 0000000000..83ed65fe9c --- /dev/null +++ b/.changeset/auth-config-stop-advertising-reserved-features.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': major +'@objectstack/plugin-auth': patch +--- + +refactor(auth)!: stop advertising `passkeys` / `magicLink` on `/api/v1/auth/config` — two flags nothing consumed (#7481, ADR-0049) + + + +**FROM → TO:** reading `config.features.passkeys` or `config.features.magicLink` off +`GET /api/v1/auth/config` → delete the read; both keys are gone from the payload and there +is no replacement flag. Neither capability was reachable by a user, so nothing a client +gated on them was ever offered. `AuthPluginConfig.plugins.passkeys` / `plugins.magicLink` +are **unchanged** — this narrows the served payload, not the server configuration. + +Both flags were served from introduction and read by no client: no login UI anywhere +renders a passkey or magic-link affordance off them. So the payload advertised two sign-in +methods a user could never reach, and a deployer who set either plugin flag flipped a +switch with no observable effect — ADR-0049's enforce-or-remove, on a deployment-facing +contract. The maintainer ruled remove over keep-as-reserved on 2026-08-11: declared = +enforced, and a deployer must not be able to flip a flag that does nothing anywhere. + +The two are not equally empty, and the prescriptions say so separately rather than sharing +one string: + +- **`passkeys`** has nothing behind it at all — no better-auth passkey plugin is wired, so + `/passkey/*` does not answer. There is no capability to detect. +- **`magicLink`** loses only its **advertisement**. `plugins.magicLink` still wires + better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` + answer exactly as before — drive them from your own UI. + +Both return to the payload in the change that ships the login UI (objectui#4179); until +then the standing record is `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in +`kernel/public-auth-features.ts`, and their `PUBLIC_AUTH_FEATURES` entries — which pointed +at the now-closed objectui#2514 — are gone with them. + +The retirement kit: + +- **Tombstone, not deletion** (`retiredKey()`): `AuthFeaturesConfigSchema` is not + `.strict()`, so a plain delete would let a payload carrying either key parse clean and + lose it in silence (the ADR-0104 shape). Each key carries its own prescription. +- **ADR-0087 D3 `SemanticMigration`** (`auth-config-unadvertised-reserved-features`) plus + the two exact `RETIRED_KEYS_BY_MAJOR` entries. No D2 conversion, deliberately: this is a + response surface the server mints per request — nobody authors or persists an + `AuthFeaturesConfig` — so there is no source for `os migrate meta` to rewrite. The + `EnhancedApiError.fieldErrors` disposition. +- `requiresFeature` narrows with the registry: neither name is a gateable flag any more, + which is what stops a spec input from being written against a capability that is not + served. +- Generated baselines (`authorable-surface/api.json` gains two `[RETIRED]` lines, + `authorable-defaults/api.json` loses two default lines), `spec-changes.json`, the upgrade + guide, `export-origins/` and the reference docs regenerated. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 4d02d2afb5..95097c70c0 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -595,7 +595,9 @@ const response = await fetch('http://localhost:3000/api/v1/auth/two-factor/verif > **Not yet implemented.** Passkey/WebAuthn support is not currently wired into > `AuthPlugin`. The `plugins: { passkeys: true }` flag is accepted but no > passkey plugin is registered, so `/passkey/*` endpoints are not available. -> This section will be updated once passkey support ships. +> Since nothing is behind it, `GET /api/v1/auth/config` stopped reporting a +> `features.passkeys` flag in protocol 17 (#7481) — there is no capability for +> a client to detect. This section will be updated once passkey support ships. ### Magic Links @@ -611,6 +613,14 @@ new AuthPlugin({ }) ``` +> **Server-side only, for now.** The endpoints below work whenever +> `plugins.magicLink` is on, but no shipped login UI drives them — so +> `GET /api/v1/auth/config` stopped advertising a `features.magicLink` flag in +> protocol 17 (#7481): a served flag no client reads told deployers a sign-in +> option existed that users could never reach. Call the endpoints from your own +> UI. The flag returns alongside the built-in magic-link login screen +> (objectui#4179). + #### Send Magic Link {/* os:check */} diff --git a/content/docs/permissions/sso.mdx b/content/docs/permissions/sso.mdx index f50c0b088e..5eb1fc115c 100644 --- a/content/docs/permissions/sso.mdx +++ b/content/docs/permissions/sso.mdx @@ -286,7 +286,7 @@ Expected response (the endpoint wraps `getPublicConfig()` in a `{ success, data { "id": "github", "name": "GitHub", "enabled": true, "type": "social" }, { "id": "okta", "name": "Okta SSO", "enabled": true, "type": "oidc" } ], - "features": { "twoFactor": false, "passkeys": false, "magicLink": false, "organization": true } + "features": { "twoFactor": false, "organization": true } } } ``` diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 7fc3dd9902..295e422a61 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -55,8 +55,8 @@ const result = AuthEndpointSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **twoFactor** | `boolean` | ✅ | Two-factor authentication enabled | -| **passkeys** | `boolean` | ✅ | Passkey/WebAuthn support enabled | -| **magicLink** | `boolean` | ✅ | Magic link login enabled | +| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (#7481, ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI (objectui#4179), classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | +| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (#7481, ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for objectui#4179, which restores this flag along with the login UI that reads it. | | **organization** | `boolean` | ✅ | Multi-tenant organization support enabled | | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | | **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, #2766 V1.5) | @@ -155,7 +155,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **emailPassword** | `{ enabled: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean }` | ✅ | Email/password authentication config | | **socialProviders** | `{ id: string; name: string; enabled: boolean; type: Enum<'social' \| 'oidc'> }[]` | ✅ | Available social/OAuth providers | -| **features** | `{ twoFactor: boolean; passkeys: boolean; magicLink: boolean; organization: boolean; … }` | ✅ | Enabled authentication features | +| **features** | `{ twoFactor: boolean; organization: boolean; ssoEnforced?: boolean; phoneNumber?: boolean; … }` | ✅ | Enabled authentication features | --- diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 77bc1e2ca3..049e99246b 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -82,7 +82,7 @@ const result = ActionSchema.parse(data); | **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. | | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. | -| **requiresFeature** | `Enum<'twoFactor' \| 'passkeys' \| 'magicLink' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| … +3 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | +| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to rewrite existing sources automatically. | @@ -105,22 +105,6 @@ const result = ActionSchema.parse(data); | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -### Allowed Values: `Action.requiresFeature` - -* `twoFactor` -* `passkeys` -* `magicLink` -* `organization` -* `multiOrgEnabled` -* `degradedTenancy` -* `oidcProvider` -* `sso` -* `ssoEnforced` -* `deviceAuthorization` -* `admin` -* `phoneNumber` -* `phoneNumberOtp` - --- @@ -176,7 +160,7 @@ const result = ActionSchema.parse(data); | **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. | | **defaultFromRow** | `boolean` | optional | | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. | -| **requiresFeature** | `Enum<'twoFactor' \| 'passkeys' \| 'magicLink' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| … +3 more>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. | +| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. | ### Allowed Values: `ActionParam.type` @@ -230,22 +214,6 @@ const result = ActionSchema.parse(data); * `tags` * `vector` -### Allowed Values: `ActionParam.requiresFeature` - -* `twoFactor` -* `passkeys` -* `magicLink` -* `organization` -* `multiOrgEnabled` -* `degradedTenancy` -* `oidcProvider` -* `sso` -* `ssoEnforced` -* `deviceAuthorization` -* `admin` -* `phoneNumber` -* `phoneNumberOtp` - --- diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 46f403a7aa..31cc8df101 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -325,6 +325,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`api-runtime-create-withdrawn`** — `PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)` → Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage` - Why not automatic: The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311). - Done when: No caller creates or updates an `api` item through the runtime metadata API. `PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` as well as direct-active, because the gate runs before the draft/publish branch and does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaring `apis:` still compiles, still passes `validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load (`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was always the only one that served. An operator who genuinely needs the runtime door back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and the endpoint still will not be served, which is why it is a diagnostic and not a workaround. Any `api` rows already sitting in `sys_metadata` from before this change were never served either; they can be deleted (`deleteMetaItem` is deliberately not gated by this refusal, so repair stays possible). +- **`auth-config-unadvertised-reserved-features`** — `api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised) + - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481. + - Done when: No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; a client that gated UI on either now treats the capability as absent rather than reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts `/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call directly. - **`batch-options-validate-only-retired`** — `api.batchOptions.validateOnly` → (removed — no dry-run today; open an issue to design a no-commit batch preview) - Why not automatic: The `validateOnly` key promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it. - Done when: No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription. diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 1330ec344b..cbdaf6c158 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -161,7 +161,7 @@ "an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud NOT_SUPPORTED)" ], "knownGaps": [ - "magic-link and passkeys: the server flags exist (AuthPluginConfigSchema.magicLink/passkeys) but objectui ships NO login UI for either — advertised-but-unconsumed, tracked in objectui#2514. Run these variants as blocked(dependency, objectui#2514) at the browser lane; the flag-advertisement clause still applies", + "magic-link and passkeys: the AuthPluginConfigSchema flags still exist, but objectui ships NO login UI for either, so #7481 withdrew both from the /api/v1/auth/config payload (protocol 17) — features.magicLink / features.passkeys are now ABSENT by design, not false. Run these variants as blocked(dependency, objectui#4179) at the browser lane. The flag-advertisement clause inverts here: seeing either key in the payload is itself a FAIL now, and magic-link's endpoints (/magic-link/send, /magic-link/verify) stay live and drivable without a UI", "the two spec files disagree on the device-flow paths (packages/spec/src/api/auth-endpoints.zod.ts: /device/request, /device/token, /device/approve vs packages/spec/src/system/auth-config.zod.ts: /device/code, /device/token, /device, /device/approve, /device/deny) — the LIVE better-auth surface serves POST /device/code, /device/token, /device/approve, /device/deny and GET /device (auth-route-ledger.ts BETTER_AUTH_MOUNTED_SURFACE); trust the live routes and file the doc divergence", "the password-reset path names ALSO diverge from the spec: AuthEndpointPaths.forgetPassword = '/forget-password' but the live catch-all serves POST /request-password-reset, POST /reset-password and GET /reset-password/:token (BETTER_AUTH_MOUNTED_SURFACE) — trust the live routes (this is the same divergence identity-auth.self-service-password-reset drives)" ] @@ -244,7 +244,8 @@ "negative": [ "a silent 200 on any disabled method's endpoint is a FAIL — a gate that only hides the button is not a gate", "phone OTP hanging or returning 2xx with no SMS service is a FAIL (the spec's own contract is 'loudly NOT_SUPPORTED')", - "ticking magic-link or passkeys as pass at the browser lane is a false positive — there is no UI to drive (objectui#2514); the honest verdict is blocked", + "ticking magic-link or passkeys as pass at the browser lane is a false positive — there is no UI to drive (objectui#4179); the honest verdict is blocked", + "features.magicLink or features.passkeys appearing in the /api/v1/auth/config payload is a FAIL — #7481 withdrew both until objectui#4179 ships the UI, so their return means the stop-advertising posture regressed", "a discovery document whose issuer/endpoints point at a base the server does not actually mount is a FAIL — a wrong .well-known breaks every downstream RP/relying party silently" ], "variants": [ @@ -255,8 +256,8 @@ "social OAuth (socialProviders map, per-provider enabled)", "device authorization grant (RFC 8628 — CLI/TV login)", "two-factor (server-driven challenge, ADR-0069)", - "magic link (flag exists; no login UI — blocked, objectui#2514)", - "passkeys (flag exists; no login UI — blocked, objectui#2514)" + "magic link (server endpoints live; no login UI and no advertised flag since #7481 — blocked, objectui#4179)", + "passkeys (plugin flag accepted but nothing wired; no advertised flag since #7481 — blocked, objectui#4179)" ], "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts" }, "traps": ["hydration-race", "dispatcher-vs-hono-route", "wrong-persona"], @@ -264,7 +265,7 @@ "packages/spec/src/system/auth-config.zod.ts (AuthPluginConfigSchema: phoneNumber/twoFactor/deviceAuthorization/magicLink/passkeys; socialProviders; oidcProviders; EmailAndPasswordConfigSchema)", "packages/spec/src/api/auth-endpoints.zod.ts (AuthEndpointPaths; AuthFeaturesConfigSchema; device-flow response schemas)", "packages/plugins/plugin-auth/src/auth-route-ledger.ts (BETTER_AUTH_MOUNTED_SURFACE: the live change-email/delete-user + /.well-known/* rows; auth-plugin.ts mounts the two discovery docs at app root)", - "packages/spec/src/kernel/public-auth-features.ts (flag semantics, gated inputs, objectui#2513/#2514 known gaps)", + "packages/spec/src/kernel/public-auth-features.ts (flag semantics, gated inputs, the objectui#2513 known gap, and PUBLIC_AUTH_FEATURES_NOT_ADVERTISED — the reserved-but-unserved record for magicLink/passkeys)", "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts" ], "history": [ diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index d9a288bf43..03d824942b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2308,8 +2308,10 @@ describe('AuthManager', () => { // Should include features expect(config.features).toEqual({ twoFactor: true, - passkeys: false, - magicLink: false, + // [#7481] `passkeys` / `magicLink` are deliberately absent — withdrawn + // from the payload by the maintainer ruling of 2026-08-11 until + // objectui#4179 ships a login UI that reads them. `toEqual` is an exact + // match, so this asserts their absence rather than merely omitting them. organization: true, oidcProvider: false, sso: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index b9f63f9e23..1962e22fb8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3455,10 +3455,15 @@ export class AuthManager { const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); + // [#7481, maintainer ruling 2026-08-11] `passkeys` / `magicLink` are + // deliberately NOT advertised here — see PUBLIC_AUTH_FEATURES_NOT_ADVERTISED + // in @objectstack/spec/kernel. Both were served from introduction with no + // login UI at any consumer, so a deployer could flip a flag that did nothing + // anywhere (declared = enforced). They come back with the UI (objectui#4179). + // `magicLink`'s server endpoints are unaffected: `plugins.magicLink` still + // wires better-auth's magic-link plugin in buildPluginList(). const features = { twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false, - passkeys: pluginConfig.passkeys ?? false, - magicLink: pluginConfig.magicLink ?? false, organization: pluginConfig.organization ?? true, multiOrgEnabled, // ADR-0105 D1 — WHICH posture is in force. `multiOrgEnabled` stays the diff --git a/packages/plugins/plugin-auth/src/public-feature-registry.test.ts b/packages/plugins/plugin-auth/src/public-feature-registry.test.ts index c8847e3d1f..0b8f2fa3e4 100644 --- a/packages/plugins/plugin-auth/src/public-feature-registry.test.ts +++ b/packages/plugins/plugin-auth/src/public-feature-registry.test.ts @@ -17,6 +17,7 @@ import { describe, expect, it, vi } from 'vitest'; import { PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS, + PUBLIC_AUTH_FEATURES_NOT_ADVERTISED, PUBLIC_AUTH_FEATURE_NAMES, PUBLIC_AUTH_FEATURES, } from '@objectstack/spec/kernel'; @@ -64,6 +65,22 @@ describe('public feature-flag registry drift guard (#2874)', () => { expect(booleans(variant)).toEqual(booleans(defaults)); }); + // #7481. The key-set equivalence above already fails if one of these is + // re-added to BOTH sides — this case covers the shape that ruling actually + // withdrew: the flag served because a deployer turned the plugin flag ON. + // With the two dropped from the registry, `servedFeatures()` at defaults says + // nothing about `plugins: { passkeys: true }`, which is exactly the + // configuration that used to advertise a capability with no consumer. + it('reserved-but-unadvertised capabilities stay out of the payload even when their plugin flag is on', () => { + const features = servedFeatures({ + plugins: { passkeys: true, magicLink: true }, + } as never); + for (const name of PUBLIC_AUTH_FEATURES_NOT_ADVERTISED) { + expect(features, `features.${name} must not be advertised until objectui#4179 ships its UI`) + .not.toHaveProperty(name); + } + }); + it('registry default semantics match the served defaults', () => { // `default-on` flags must actually serve `true` by default and `opt-in` // flags `false` — otherwise the lowered `!= false` / `== true` predicates diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index f9a20d4f61..7326367def 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -257,6 +257,7 @@ "PROTOCOL_VERSION (const)", "PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)", "PUBLIC_AUTH_FEATURES (const)", + "PUBLIC_AUTH_FEATURES_NOT_ADVERTISED (const)", "PUBLIC_AUTH_FEATURE_NAMES (const)", "PackageArtifact (type)", "PackageArtifactParsed (type)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 2b4a8b786a..ba5f9f98b9 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -23,9 +23,7 @@ "api/ApiTestingUiConfig:path = \"/api-docs\"", "api/ApiTestingUiConfig:syntaxHighlighting = true", "api/ApiTestingUiConfig:theme = \"light\"", - "api/AuthFeaturesConfig:magicLink = false", "api/AuthFeaturesConfig:organization = false", - "api/AuthFeaturesConfig:passkeys = false", "api/AuthFeaturesConfig:twoFactor = false", "api/AuthProviderInfo:type = \"social\"", "api/BatchConfig:enabled = true", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index e52c34ea75..fc2adf9985 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -196,9 +196,9 @@ "api/AuthEndpoint:signOut", "api/AuthEndpoint:signUpEmail", "api/AuthEndpoint:verifyEmail", - "api/AuthFeaturesConfig:magicLink", + "api/AuthFeaturesConfig:magicLink [RETIRED]", "api/AuthFeaturesConfig:organization", - "api/AuthFeaturesConfig:passkeys", + "api/AuthFeaturesConfig:passkeys [RETIRED]", "api/AuthFeaturesConfig:phoneNumber", "api/AuthFeaturesConfig:phoneNumberOtp", "api/AuthFeaturesConfig:ssoEnforced", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 02c4b48dbd..689307e3f0 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -257,6 +257,7 @@ "PROTOCOL_VERSION": "src/kernel/protocol-version.ts#PROTOCOL_VERSION (const)", "PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS": "src/kernel/public-auth-features.ts#PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)", "PUBLIC_AUTH_FEATURES": "src/kernel/public-auth-features.ts#PUBLIC_AUTH_FEATURES (const)", + "PUBLIC_AUTH_FEATURES_NOT_ADVERTISED": "src/kernel/public-auth-features.ts#PUBLIC_AUTH_FEATURES_NOT_ADVERTISED (const)", "PUBLIC_AUTH_FEATURE_NAMES": "src/kernel/public-auth-features.ts#PUBLIC_AUTH_FEATURE_NAMES (const)", "PackageArtifact": "src/kernel/package-artifact.zod.ts#PackageArtifact (type)", "PackageArtifactParsed": "src/kernel/package-artifact.zod.ts#PackageArtifactParsed (type)", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 21f33ad528..a3d64057f5 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -532,6 +532,13 @@ "toMajor": 17, "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)." }, + { + "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", + "replacement": "(removed — no replacement flag; the capabilities are not advertised)", + "migrationId": "auth-config-unadvertised-reserved-features", + "toMajor": 17, + "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." + }, { "surface": "api.batchOptions.validateOnly", "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", @@ -1416,6 +1423,13 @@ "toMajor": 17, "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)." }, + { + "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", + "replacement": "(removed — no replacement flag; the capabilities are not advertised)", + "migrationId": "auth-config-unadvertised-reserved-features", + "toMajor": 17, + "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." + }, { "surface": "api.batchOptions.validateOnly", "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index f23ae57f1c..f3d89490f0 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -5,6 +5,7 @@ import { AuthEndpointPaths, AuthEndpointSchema, AuthEndpointAliases, + AuthFeaturesConfigSchema, EndpointMapping, getAuthEndpointUrl, } from './auth-endpoints.zod'; @@ -101,6 +102,35 @@ describe('AuthEndpointSchema', () => { }); }); +// #7481 — the two flags withdrawn from `/api/v1/auth/config` by the maintainer +// ruling of 2026-08-11. `AuthFeaturesConfigSchema` is not `.strict()`, so a +// plain deletion would have Zod SILENTLY STRIP a key a client kept sending and +// leave nothing to grep (#3733, ADR-0104) — the tombstone is what makes the +// withdrawal audible, so it is pinned on both legs: the prescription a writer +// hits, and the absence a reader gets. +describe('AuthFeaturesConfig retired flags (#7481)', () => { + const valid = { twoFactor: false, organization: true }; + + it('rejects `passkeys` with its own prescription, naming the missing consumer', () => { + expect(() => AuthFeaturesConfigSchema.parse({ ...valid, passkeys: true })) + .toThrow(/`features\.passkeys` was removed.*Delete the key.*objectui#4179/s); + }); + + it('rejects `magicLink` with a prescription that keeps its endpoints alive', () => { + // Deliberately NOT the same string as passkeys: magic-link's better-auth + // endpoints still answer, and a shared prescription would tell a magic-link + // deployer to stop using a capability that was never withdrawn. + expect(() => AuthFeaturesConfigSchema.parse({ ...valid, magicLink: true })) + .toThrow(/`features\.magicLink` was removed.*magic-link\/send.*objectui#4179/s); + }); + + it('does not serve either key on a clean parse', () => { + const parsed = AuthFeaturesConfigSchema.parse(valid); + expect(parsed).not.toHaveProperty('passkeys'); + expect(parsed).not.toHaveProperty('magicLink'); + }); +}); + describe('AuthEndpointAliases', () => { it('should map common names to canonical endpoints', () => { expect(AuthEndpointAliases.login).toBe('/sign-in/email'); diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 6ce4b6d4a5..af1beddbfc 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -24,6 +24,7 @@ import { z } from 'zod'; * Based on better-auth's endpoint structure. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const AuthEndpointPaths = { // Email/Password Authentication signInEmail: '/sign-in/email', @@ -190,13 +191,44 @@ export const EmailPasswordConfigPublicSchema = lazySchema(() => z.object({ requireEmailVerification: z.boolean().optional().describe('Whether email verification is required'), })); +/** + * `passkeys` / `magicLink` were withdrawn from the `/api/v1/auth/config` payload + * in #7481 (maintainer ruling 2026-08-11) — see + * `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in `kernel/public-auth-features.ts` for + * the standing record and the condition under which they come back. + * + * The two prescriptions differ because the two capabilities differ: nothing at + * all is behind `passkeys`, while `magicLink`'s better-auth endpoints are live + * and only their advertisement was withdrawn. A single shared string would have + * told half the readers something false. + */ +const PASSKEYS_UNADVERTISED = + '`features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 ' + + '(#7481, ADR-0049) — it was served from introduction and consumed by nothing: no login ' + + 'UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a ' + + 'deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour ' + + 'anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not ' + + 'a capability this platform offers yet. It returns to this payload in the change that ' + + 'ships the login UI (objectui#4179), classified in PUBLIC_AUTH_FEATURES again at that ' + + 'point — do not re-add it ahead of a consumer.'; + +const MAGIC_LINK_UNADVERTISED = + '`features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 ' + + '(#7481, ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders ' + + 'a magic-link sign-in affordance off this flag, so it only told a deployer that a UI ' + + 'existed when none did. Delete the key. The server side is unchanged and still yours to ' + + 'call: `AuthPluginConfig.plugins.magicLink` wires better-auth\'s magic-link plugin, and ' + + '`/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive ' + + 'them from your own UI, or wait for objectui#4179, which restores this flag along with ' + + 'the login UI that reads it.'; + /** * Auth Features Configuration (Public) */ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ twoFactor: z.boolean().default(false).describe('Two-factor authentication enabled'), - passkeys: z.boolean().default(false).describe('Passkey/WebAuthn support enabled'), - magicLink: z.boolean().default(false).describe('Magic link login enabled'), + passkeys: retiredKey(PASSKEYS_UNADVERTISED), + magicLink: retiredKey(MAGIC_LINK_UNADVERTISED), organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'), ssoEnforced: z.boolean().optional().describe( 'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)', diff --git a/packages/spec/src/kernel/public-auth-features.test.ts b/packages/spec/src/kernel/public-auth-features.test.ts index d806fa1e80..bca78079d5 100644 --- a/packages/spec/src/kernel/public-auth-features.test.ts +++ b/packages/spec/src/kernel/public-auth-features.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { PUBLIC_AUTH_FEATURES, + PUBLIC_AUTH_FEATURES_NOT_ADVERTISED, PUBLIC_AUTH_FEATURE_NAMES, featureGatePredicate, lowerRequiresFeature, @@ -15,18 +16,16 @@ import { describe('PUBLIC_AUTH_FEATURES registry', () => { const entries = Object.entries(PUBLIC_AUTH_FEATURES); - it('classifies all 13 public flags', () => { - expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(13); + it('classifies all 11 public flags', () => { + expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(11); expect([...PUBLIC_AUTH_FEATURE_NAMES].sort()).toEqual( [ 'admin', 'degradedTenancy', 'deviceAuthorization', - 'magicLink', 'multiOrgEnabled', 'oidcProvider', 'organization', - 'passkeys', 'phoneNumber', 'phoneNumberOtp', 'sso', @@ -36,6 +35,20 @@ describe('PUBLIC_AUTH_FEATURES registry', () => { ); }); + // #7481. The negative record has to be pinned as a negative: an entry in + // PUBLIC_AUTH_FEATURES is what makes a flag servable AND `requiresFeature`- + // gateable, so re-adding one of these ahead of its UI is exactly the + // regression the ruling withdrew them to prevent — and it would otherwise be + // a green two-line change. + it('reserved-but-unadvertised capabilities are absent from the registry', () => { + expect([...PUBLIC_AUTH_FEATURES_NOT_ADVERTISED].sort()).toEqual(['magicLink', 'passkeys']); + for (const name of PUBLIC_AUTH_FEATURES_NOT_ADVERTISED) { + expect(PUBLIC_AUTH_FEATURES, `${name} must not be classified while nothing consumes it`) + .not.toHaveProperty(name); + expect(PUBLIC_AUTH_FEATURE_NAMES as readonly string[]).not.toContain(name); + } + }); + it.each(entries)('%s declares gatedInputs XOR an exemption reason', (_name, entry) => { const hasGates = entry.gatedInputs !== undefined && entry.gatedInputs.length > 0; const hasExempt = entry.exempt !== undefined && entry.exempt.reason.length > 0; diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 4261e05645..bbeb54d87d 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -98,26 +98,6 @@ export const PUBLIC_AUTH_FEATURES = { 'Login-surface 2FA challenge is server-driven remediation (ADR-0069), ' + 'so the flag is intentionally unread by objectui LoginForm.', }, - passkeys: { - surface: 'login', - semantics: 'opt-in', - exempt: { - reason: - 'No spec input to gate. Typed in objectui (auth/src/types.ts) but no ' + - 'passkey UI exists yet — advertised-but-unconsumed gap tracked in ' + - 'objectui#2514 (#2874 P2②).', - }, - }, - magicLink: { - surface: 'login', - semantics: 'opt-in', - exempt: { - reason: - 'No spec input to gate. Typed in objectui (auth/src/types.ts) but no ' + - 'magic-link UI exists yet — advertised-but-unconsumed gap tracked in ' + - 'objectui#2514 (#2874 P2②).', - }, - }, organization: { surface: 'crud', semantics: 'default-on', @@ -268,6 +248,33 @@ export const PUBLIC_AUTH_FEATURE_NAMES = Object.keys(PUBLIC_AUTH_FEATURES) as [ */ export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl', 'tenancyPosture'] as const; +/** + * Capabilities that are RESERVED but deliberately **not advertised** — the + * server-side plugin flag may exist in `AuthPluginConfig`, but nothing is + * published on `/api/v1/auth/config` because no consumer can act on it. + * + * Why this list exists rather than a registry entry: {@link PUBLIC_AUTH_FEATURES} + * classifies flags that ARE served (the plugin-auth drift guard asserts key-set + * equivalence with `getPublicConfig()`), so an unserved flag has no honest entry + * shape there — and leaving it served-but-exempt is precisely what this list + * records the retirement of. Membership here is the *negative* record: these + * names must be absent from {@link PUBLIC_AUTH_FEATURES}, absent from the + * `features` payload, and therefore un-gateable via `requiresFeature`. + * + * - `passkeys` / `magicLink` (#7481, maintainer ruling 2026-08-11): both were + * advertised from introduction with no login UI at either consumer — an + * advertised-but-unconsumed capability, so a deployer could flip a flag that + * did nothing anywhere. objectui#2514 documented them as reserved on the + * consumer side (objectui PR #4182) and closed; the login UI that would make + * them real is scoped as **objectui#4179**. They return to + * {@link PUBLIC_AUTH_FEATURES} in the change that ships that UI — not before. + * + * `magicLink` remains a live **server** capability (`AuthPluginConfig.plugins.magicLink` + * still wires better-auth's magic-link endpoints); what was withdrawn is the + * public advertisement of it, not the endpoints. + */ +export const PUBLIC_AUTH_FEATURES_NOT_ADVERTISED = ['passkeys', 'magicLink'] as const; + /** * The canonical CEL gate for a flag, per its default semantics: * `opt-in` → `features.X == true`; `default-on` → `features.X != false` diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts new file mode 100644 index 0000000000..a2e10323ed --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7481 — the sibling of `api/AuthFeaturesConfig:passkeys`, retired in the same +// maintainer ruling (2026-08-11) and for the same reason, but with one +// difference a reader of this table must not lose: what was inert here is the +// ADVERTISEMENT, not the capability. `AuthPluginConfig.plugins.magicLink` still +// wires better-auth's magic-link plugin and `/api/v1/auth/magic-link/{send,verify}` +// still answer — no client just renders anything off the served flag, so the +// flag alone was the false promise. The prescription says so explicitly rather +// than reusing its sibling's string. +// +// Same response-surface disposition as its sibling: no D2 conversion (nothing +// authors an `AuthFeaturesConfig`), prescription carried by this tombstone plus +// the D3 semantic entry `auth-config-unadvertised-reserved-features`. +export const entry = 'api/AuthFeaturesConfig:magicLink'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts new file mode 100644 index 0000000000..21d69da324 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7481 — the `/api/v1/auth/config` `features` payload stops advertising the +// two capabilities no client can act on (maintainer ruling 2026-08-11, +// declared = enforced: a deployer must not be able to flip a flag that does +// nothing anywhere). `passkeys` is the emptier of the pair — no login UI reads +// it AND no better-auth passkey plugin is wired behind it. +// +// Registered here but NOT in `src/conversions/registry.ts`, for the same reason +// as the `api/ListNotifications{Request,Response}:cursor` pair: this is a +// RESPONSE surface — the server mints it on every `GET /auth/config` and +// nobody authors or persists an `AuthFeaturesConfig` — so there is no source +// for `os migrate meta` to rewrite. The prescription reaches consumers as the +// D3 semantic entry `auth-config-unadvertised-reserved-features` plus this +// tombstone, the `EnhancedApiError.fieldErrors` disposition. +// +// The withdrawal is conditional, not permanent: the flags return in the change +// that ships the login UI (objectui#4179). Until then the standing record is +// `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in `kernel/public-auth-features.ts`. +export const entry = 'api/AuthFeaturesConfig:passkeys'; diff --git a/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts new file mode 100644 index 0000000000..d414af7732 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'auth-config-unadvertised-reserved-features', + surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink', + replacement: '(removed — no replacement flag; the capabilities are not advertised)', + reason: + 'Both flags were served by `GET /api/v1/auth/config` from introduction and read by no ' + + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' + + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' + + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' + + 'effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove ' + + 'over keep-as-reserved). The two are not equally empty: nothing at all is wired behind ' + + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' + + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' + + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' + + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' + + 'conditional: both return to the payload in the change that ships the login UI ' + + '(objectui#4179). ADR-0049, #7481.', + acceptanceCriteria: + 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' + + 'a client that gated UI on either now treats the capability as absent rather than ' + + 'reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` ' + + 'with either key fails to parse with its own prescription instead of being silently ' + + 'stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts ' + + '`/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call ' + + 'directly.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e6f785236e..c402ec9739 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1761,6 +1761,32 @@ const step17: MigrationStep = { + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not ' + 'gated by this refusal, so repair stays possible).', }, + { + id: 'auth-config-unadvertised-reserved-features', + surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink', + replacement: '(removed — no replacement flag; the capabilities are not advertised)', + reason: + 'Both flags were served by `GET /api/v1/auth/config` from introduction and read by no ' + + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' + + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' + + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' + + 'effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove ' + + 'over keep-as-reserved). The two are not equally empty: nothing at all is wired behind ' + + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' + + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' + + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' + + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' + + 'conditional: both return to the payload in the change that ships the login UI ' + + '(objectui#4179). ADR-0049, #7481.', + acceptanceCriteria: + 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' + + 'a client that gated UI on either now treats the capability as absent rather than ' + + 'reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` ' + + 'with either key fails to parse with its own prescription instead of being silently ' + + 'stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts ' + + '`/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call ' + + 'directly.', + }, { id: 'batch-options-validate-only-retired', surface: 'api.batchOptions.validateOnly', @@ -3784,6 +3810,37 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #7481 — the sibling of `api/AuthFeaturesConfig:passkeys`, retired in the same + // maintainer ruling (2026-08-11) and for the same reason, but with one + // difference a reader of this table must not lose: what was inert here is the + // ADVERTISEMENT, not the capability. `AuthPluginConfig.plugins.magicLink` still + // wires better-auth's magic-link plugin and `/api/v1/auth/magic-link/{send,verify}` + // still answer — no client just renders anything off the served flag, so the + // flag alone was the false promise. The prescription says so explicitly rather + // than reusing its sibling's string. + // + // Same response-surface disposition as its sibling: no D2 conversion (nothing + // authors an `AuthFeaturesConfig`), prescription carried by this tombstone plus + // the D3 semantic entry `auth-config-unadvertised-reserved-features`. + 'api/AuthFeaturesConfig:magicLink', + // #7481 — the `/api/v1/auth/config` `features` payload stops advertising the + // two capabilities no client can act on (maintainer ruling 2026-08-11, + // declared = enforced: a deployer must not be able to flip a flag that does + // nothing anywhere). `passkeys` is the emptier of the pair — no login UI reads + // it AND no better-auth passkey plugin is wired behind it. + // + // Registered here but NOT in `src/conversions/registry.ts`, for the same reason + // as the `api/ListNotifications{Request,Response}:cursor` pair: this is a + // RESPONSE surface — the server mints it on every `GET /auth/config` and + // nobody authors or persists an `AuthFeaturesConfig` — so there is no source + // for `os migrate meta` to rewrite. The prescription reaches consumers as the + // D3 semantic entry `auth-config-unadvertised-reserved-features` plus this + // tombstone, the `EnhancedApiError.fieldErrors` disposition. + // + // The withdrawal is conditional, not permanent: the flags return in the change + // that ships the login UI (objectui#4179). Until then the standing record is + // `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in `kernel/public-auth-features.ts`. + 'api/AuthFeaturesConfig:passkeys', // #6361 — the notification-inbox pagination key, tombstoned on BOTH halves // of `GET /api/v1/notifications` because one capability is never half- // deleted (maintainer ruling 2026-08-07, ruled jointly with #6363). Two