Skip to content

Commit 86f7a20

Browse files
os-zhuangclaude
andauthored
refactor(auth)!: stop advertising passkeys/magicLink on /api/v1/auth/config (#7481) (#7508)
Both flags were served by `getPublicConfig()` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them. The payload therefore 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 anywhere — ADR-0049 enforce-or-remove, on a deployment-facing contract. Maintainer ruling 2026-08-11 chose stop-advertising over keep-as-reserved: declared = enforced. The two capabilities are not equally empty, and the prescriptions say so separately rather than sharing one string. Nothing is wired behind `passkeys`. `magicLink` loses only its ADVERTISEMENT: `plugins.magicLink` still wires better-auth's magic-link plugin and `/magic-link/{send,verify}` answer as before. - `AuthFeaturesConfigSchema`: both keys tombstoned with `retiredKey()` rather than deleted — the schema is not `.strict()`, so a plain delete would let a payload carrying either key parse clean and lose it in silence (ADR-0104). - ADR-0087: one D3 `SemanticMigration` plus the two exact `RETIRED_KEYS_BY_MAJOR` entries. No D2 conversion, deliberately — a response surface has no authored source for `os migrate meta` to rewrite (the `EnhancedApiError.fieldErrors` disposition). - `PUBLIC_AUTH_FEATURES`: the two entries are removed, which is what the drift guard requires once the served set loses them, and it also narrows `requiresFeature` so no spec input can gate on an unserved flag. Their standing record moves to the new `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED`, re-anchored off closed objectui#2514 onto objectui#4179. - Generated baselines, spec-changes, upgrade guide, export-origins and reference docs regenerated; hand-written auth/SSO docs and the identity-auth QA checklist corrected. Claude-Session: https://claude.ai/code/session_01M33yPsTgKmkB5rh2WzZwQk Co-authored-by: Claude <noreply@anthropic.com>
1 parent e51acd6 commit 86f7a20

23 files changed

Lines changed: 354 additions & 78 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
refactor(auth)!: stop advertising `passkeys` / `magicLink` on `/api/v1/auth/config` — two flags nothing consumed (#7481, ADR-0049)
7+
8+
<!-- adr-0087: registered auth-config-unadvertised-reserved-features -->
9+
10+
**FROM → TO:** reading `config.features.passkeys` or `config.features.magicLink` off
11+
`GET /api/v1/auth/config` → delete the read; both keys are gone from the payload and there
12+
is no replacement flag. Neither capability was reachable by a user, so nothing a client
13+
gated on them was ever offered. `AuthPluginConfig.plugins.passkeys` / `plugins.magicLink`
14+
are **unchanged** — this narrows the served payload, not the server configuration.
15+
16+
Both flags were served from introduction and read by no client: no login UI anywhere
17+
renders a passkey or magic-link affordance off them. So the payload advertised two sign-in
18+
methods a user could never reach, and a deployer who set either plugin flag flipped a
19+
switch with no observable effect — ADR-0049's enforce-or-remove, on a deployment-facing
20+
contract. The maintainer ruled remove over keep-as-reserved on 2026-08-11: declared =
21+
enforced, and a deployer must not be able to flip a flag that does nothing anywhere.
22+
23+
The two are not equally empty, and the prescriptions say so separately rather than sharing
24+
one string:
25+
26+
- **`passkeys`** has nothing behind it at all — no better-auth passkey plugin is wired, so
27+
`/passkey/*` does not answer. There is no capability to detect.
28+
- **`magicLink`** loses only its **advertisement**. `plugins.magicLink` still wires
29+
better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify`
30+
answer exactly as before — drive them from your own UI.
31+
32+
Both return to the payload in the change that ships the login UI (objectui#4179); until
33+
then the standing record is `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in
34+
`kernel/public-auth-features.ts`, and their `PUBLIC_AUTH_FEATURES` entries — which pointed
35+
at the now-closed objectui#2514 — are gone with them.
36+
37+
The retirement kit:
38+
39+
- **Tombstone, not deletion** (`retiredKey()`): `AuthFeaturesConfigSchema` is not
40+
`.strict()`, so a plain delete would let a payload carrying either key parse clean and
41+
lose it in silence (the ADR-0104 shape). Each key carries its own prescription.
42+
- **ADR-0087 D3 `SemanticMigration`** (`auth-config-unadvertised-reserved-features`) plus
43+
the two exact `RETIRED_KEYS_BY_MAJOR` entries. No D2 conversion, deliberately: this is a
44+
response surface the server mints per request — nobody authors or persists an
45+
`AuthFeaturesConfig` — so there is no source for `os migrate meta` to rewrite. The
46+
`EnhancedApiError.fieldErrors` disposition.
47+
- `requiresFeature` narrows with the registry: neither name is a gateable flag any more,
48+
which is what stops a spec input from being written against a capability that is not
49+
served.
50+
- Generated baselines (`authorable-surface/api.json` gains two `[RETIRED]` lines,
51+
`authorable-defaults/api.json` loses two default lines), `spec-changes.json`, the upgrade
52+
guide, `export-origins/` and the reference docs regenerated.

content/docs/permissions/authentication.mdx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,9 @@ const response = await fetch('http://localhost:3000/api/v1/auth/two-factor/verif
595595
> **Not yet implemented.** Passkey/WebAuthn support is not currently wired into
596596
> `AuthPlugin`. The `plugins: { passkeys: true }` flag is accepted but no
597597
> passkey plugin is registered, so `/passkey/*` endpoints are not available.
598-
> This section will be updated once passkey support ships.
598+
> Since nothing is behind it, `GET /api/v1/auth/config` stopped reporting a
599+
> `features.passkeys` flag in protocol 17 (#7481) — there is no capability for
600+
> a client to detect. This section will be updated once passkey support ships.
599601
600602
### Magic Links
601603

@@ -611,6 +613,14 @@ new AuthPlugin({
611613
})
612614
```
613615

616+
> **Server-side only, for now.** The endpoints below work whenever
617+
> `plugins.magicLink` is on, but no shipped login UI drives them — so
618+
> `GET /api/v1/auth/config` stopped advertising a `features.magicLink` flag in
619+
> protocol 17 (#7481): a served flag no client reads told deployers a sign-in
620+
> option existed that users could never reach. Call the endpoints from your own
621+
> UI. The flag returns alongside the built-in magic-link login screen
622+
> (objectui#4179).
623+
614624
#### Send Magic Link
615625

616626
{/* os:check */}

content/docs/permissions/sso.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ Expected response (the endpoint wraps `getPublicConfig()` in a `{ success, data
286286
{ "id": "github", "name": "GitHub", "enabled": true, "type": "social" },
287287
{ "id": "okta", "name": "Okta SSO", "enabled": true, "type": "oidc" }
288288
],
289-
"features": { "twoFactor": false, "passkeys": false, "magicLink": false, "organization": true }
289+
"features": { "twoFactor": false, "organization": true }
290290
}
291291
}
292292
```

content/docs/references/api/auth-endpoints.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ const result = AuthEndpointSchema.parse(data);
5555
| Property | Type | Required | Description |
5656
| :--- | :--- | :--- | :--- |
5757
| **twoFactor** | `boolean` || Two-factor authentication enabled |
58-
| **passkeys** | `boolean` | | Passkey/WebAuthn support enabled |
59-
| **magicLink** | `boolean` | | Magic link login enabled |
58+
| **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. |
59+
| **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. |
6060
| **organization** | `boolean` || Multi-tenant organization support enabled |
6161
| **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) |
6262
| **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:
155155
| :--- | :--- | :--- | :--- |
156156
| **emailPassword** | `{ enabled: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean }` || Email/password authentication config |
157157
| **socialProviders** | `{ id: string; name: string; enabled: boolean; type: Enum<'social' \| 'oidc'> }[]` || Available social/OAuth providers |
158-
| **features** | `{ twoFactor: boolean; passkeys: boolean; magicLink: boolean; organization: boolean; … }` || Enabled authentication features |
158+
| **features** | `{ twoFactor: boolean; organization: boolean; ssoEnforced?: boolean; phoneNumber?: boolean; … }` || Enabled authentication features |
159159

160160

161161
---

content/docs/references/ui/action.mdx

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ const result = ActionSchema.parse(data);
8282
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. |
8383
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
8484
| **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. |
85-
| **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. |
85+
| **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. |
8686
| **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. |
8787
| **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. |
8888
| **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);
105105
| **_packageVersion** | `string` | optional | Owning package version. |
106106
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
107107

108-
### Allowed Values: `Action.requiresFeature`
109-
110-
* `twoFactor`
111-
* `passkeys`
112-
* `magicLink`
113-
* `organization`
114-
* `multiOrgEnabled`
115-
* `degradedTenancy`
116-
* `oidcProvider`
117-
* `sso`
118-
* `ssoEnforced`
119-
* `deviceAuthorization`
120-
* `admin`
121-
* `phoneNumber`
122-
* `phoneNumberOtp`
123-
124108

125109
---
126110

@@ -176,7 +160,7 @@ const result = ActionSchema.parse(data);
176160
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
177161
| **defaultFromRow** | `boolean` | optional | |
178162
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
179-
| **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. |
163+
| **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. |
180164

181165
### Allowed Values: `ActionParam.type`
182166

@@ -230,22 +214,6 @@ const result = ActionSchema.parse(data);
230214
* `tags`
231215
* `vector`
232216

233-
### Allowed Values: `ActionParam.requiresFeature`
234-
235-
* `twoFactor`
236-
* `passkeys`
237-
* `magicLink`
238-
* `organization`
239-
* `multiOrgEnabled`
240-
* `degradedTenancy`
241-
* `oidcProvider`
242-
* `sso`
243-
* `ssoEnforced`
244-
* `deviceAuthorization`
245-
* `admin`
246-
* `phoneNumber`
247-
* `phoneNumberOtp`
248-
249217

250218
---
251219

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049,
325325
- **`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`
326326
- 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).
327327
- 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).
328+
- **`auth-config-unadvertised-reserved-features`**`api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised)
329+
- 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.
330+
- 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.
328331
- **`batch-options-validate-only-retired`**`api.batchOptions.validateOnly` → (removed — no dry-run today; open an issue to design a no-commit batch preview)
329332
- 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.
330333
- Done when: No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription.

0 commit comments

Comments
 (0)