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
52 changes: 52 additions & 0 deletions .changeset/auth-config-stop-advertising-reserved-features.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered auth-config-unadvertised-reserved-features -->

**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.
12 changes: 11 additions & 1 deletion content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 */}
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
```
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/api/auth-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |


---
Expand Down
36 changes: 2 additions & 34 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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). |
| **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. |
Expand All @@ -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`


---

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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`


---

Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading