Skip to content

Commit aee9c47

Browse files
feat(spec): declare features.sso on the auth config response (#29455)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9JdWLq8FiHUnLyAiWdBNVWa)_ Fixes #29344 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — `git grep` for `features.sso` and `auth/config` under `skills/` finds 0 files; `content/docs/permissions/sso.mdx` already tells readers that `/auth/config` serves `features.sso` when SSO is configured, which stays true; the API reference page is regenerated in this diff ## 维护者速读 - 改了什么:spec 的 `GET /api/v1/auth/config` 响应契约 `AuthFeaturesConfigSchema` 新增可选布尔键 `sso`(服务端早就在发、登录页早就在读,只是契约没声明)。 - 为什么改:按契约解析响应的客户端会把这个键静默丢掉,看不到「用 SSO 登录」入口;声明后与服务端实发一致。 - 风险与回滚:纯加宽,以前能解析的响应原样解析;没有默认值,不发就仍是不存在;运行时代码一行未动;回滚即撤掉此 PR。 - 先看:`packages/spec/src/api/auth-endpoints.zod.ts`(新键与说明)、`packages/spec/src/api/auth-endpoints.test.ts`(新测试)、`.changeset/29344-auth-config-features-sso.md`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/api/auth-endpoints.test.ts`,预期 31 个测试全部通过。 - 需要你做的:确认新公开键名 `sso`(PR 上挂 needs-user-decision);其余席位落地。 ## What changes - **`AuthFeaturesConfigSchema.sso`** (`packages/spec/src/api/auth-endpoints.zod.ts`), an optional boolean beside `ssoEnforced`, no default. Its describe says what the producer means: true when enterprise SSO is *usable* (the domain-routed SSO plugin is wired AND at least one SSO provider is configured), false when SSO is off or on with no provider yet, absent means not usable. It names which producer value is served (the `isSsoUsable` answer, not the coarse wired flag, falling open to the wired flag when providers cannot be counted) and how it differs from `ssoEnforced` (that one takes the password form away; this one only says whether the SSO entry exists). The tracker id sits in an adjacent `//` comment only. - Regenerated: `packages/spec/authorable-surface/api.json` (`api/AuthFeaturesConfig:sso`, written by the build) and `content/docs/references/api/auth-endpoints.mdx` (`gen:docs`, the one artifact `check:generated` named stale). The same set the `features.cloudManaged` precedent (#25292, PR #25309) touched on the spec side. - Changeset `.changeset/29344-auth-config-features-sso.md`: `@objectstack/spec` `minor`, both declaration lines. **Producer readings** (origin/main `96fb7b5d`): - `packages/plugins/plugin-auth/src/auth-manager.ts` `getPublicConfig()` sets `sso: this.isSsoWired()` (the coarse "plugin wired" flag). - Both HTTP doors that answer `GET /auth/config` refine it to `isSsoUsable()` before answering: plugin-auth's `/config` route (`auth-plugin.ts`, `if (config.features?.sso) config.features.sso = await isSsoUsable()`) and the hono adapter's auth route (`packages/adapters/hono/src/index.ts`, same refinement). `git grep getPublicConfig` over `packages/` finds no third door. So a client always sees the usable answer; the unrefined value is read only inside the server (the boot sign-in reachability check, `auth-plugin.ts`), which no client sees. No producer disagreement a client can observe, so no `domain:server` finding. **Classification:** `PUBLIC_AUTH_FEATURES.sso` (`packages/spec/src/kernel/public-auth-features.ts`) is `surface: 'login'`, `opt-in`, exempt with the reason "the served value is refined to usable via isSsoUsable()": it agrees with the new describe. No pin ties the registry's keys to `AuthFeaturesConfigSchema`'s keys (the plugin-auth drift guard ties the registry to the served booleans, not to the schema), so nothing there moved. See Acceptance notes. **No liveness row.** `packages/spec/liveness/` governs metadata types; no ledger names this API response (`git grep AuthFeaturesConfig packages/spec/liveness` finds nothing), the same as for `cloudManaged`. **No forced test edits.** No existing test was changed. ## Why `GET /api/v1/auth/config` serves `features.sso` and the console's login form reads it (`ui/packages/auth/src/LoginForm.tsx`, `config?.features?.sso === true`, through a hand-typed `sso?: boolean` in `ui/packages/auth/src/types.ts`), as does the mobile shell per the card. The contract did not declare it, so a client parsing through `GetAuthConfigResponseSchema` dropped it silently. One correction to the card's premise text: `ui/packages/app-shell/src/runtime-config.ts`'s `body.features.sso === true` reads `GET /api/v1/runtime/config` (the plan-entitlement flag the cloud-connection `RuntimeConfigPlugin` passes through), a different response. The premise still holds through the LoginForm reader above. ## Tests `packages/spec/src/api/auth-endpoints.test.ts`, new block `AuthFeaturesConfig.sso (served, now declared)`, 8 cases: - a served `sso: true` and `sso: false` parse and are kept; - the whole `GetAuthConfigResponseSchema` parse keeps `features.sso` beside `ssoEnforced`; - CONTROL: an absent `sso` stays absent (no default is invented); - a non-boolean `sso` (`"yes"`, `1`, `null`) is refused with exactly one `invalid_type` issue at path `sso`, expected `boolean`; - the describe names `isSsoUsable` and `ssoEnforced` and carries no tracker id. <details><summary>Local readings (head 0d81446)</summary> - Build and regen: `pnpm --filter @objectstack/spec build` under os-verify-lock, then `check:generated` exit 1 naming one stale artifact (`check:docs`); `gen:docs` VERDICT 0; committed with the source. - `pnpm --filter @objectstack/spec test`: VERDICT command-exit 0, Test Files 791 passed (791), Tests 23474 passed, 1 todo. `src/api/auth-endpoints.test.ts` + `src/kernel/public-auth-features.test.ts`: 57 passed. - `pnpm --filter @objectstack/spec typecheck`: VERDICT 0 (test layer compiles; 131 pinned signatures held). - Consumer closure: `turbo run build --concurrency=2 --filter='./packages/**'` VERDICT 0 (78 tasks, 0 cached). - `@objectstack/client` typecheck (it re-exports `AuthFeaturesConfig` / `GetAuthConfigResponse`): VERDICT 0. Reverse check that the consumer reads the rebuilt declarations: a probe file in `packages/client` typing `{ sso: true }` as `AuthFeaturesConfig` from `@objectstack/spec/api` compiles (exit 0), and `{ sso: 'yes' }` is refused TS2322 (exit 2). Probe deleted. - `@objectstack/plugin-auth` full suite (the producer, incl. the `/config` route and `isSsoUsable` tests): VERDICT 0, Test Files 211 passed (211), Tests 3643 passed, 10 skipped. - hono adapter (`hono-auth-mount-basepath`, `hono-head-dispatched-as-get`, `hono-thrown-message-disclosure`, the files that drive its `/auth/config` door): Test Files 3 passed (3), Tests 33 passed. - ui, from `ui/`: `packages/auth/src/__tests__/LoginForm.test.tsx` (the real `/auth/config` reader) + `packages/app-shell/src/runtime-config.test.ts` (named by the card): Test Files 2 passed (2), Tests 85 passed. - Ablation: `sso` renamed to `ssoAblated` in the schema with `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `fb278750` to `8c4aae77`): `auth-endpoints.test.ts` exit 1, 7 failed, CONTROL green as expected. Restored: blob equal to HEAD `fb278750`, `git diff HEAD` empty. - Dogfood battery: n/a, no `!` in the title and no `(narrowing)` arm. </details> <details><summary>Gates (dispatch-gates --commands)</summary> - At head `0d814466`: 103 derived commands, all 103 exit 0 (each with stdin closed, exit captured before any pipe). `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 (PREREQUISITE NOT MET: its pinned fixture commit was outside the shallow clone); after `git fetch --depth=1 origin 621a487…` it exited 0 (48 cases). - `dispatch-gates --ran`: 103 derived, 103 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. - Before the gates: `turbo run build --concurrency=2 --filter='./packages/**'` under os-verify-lock, VERDICT 0 (78 tasks). - Offline PR-body checks with this body: `check-changeset-no-major.mjs --base origin/main --event` exit 0 (level axis `yes (widening)` + `added`, spec `minor`); `check-partof-closing-keyword.mjs` exit 0. - Scoped lint: `pnpm exec eslint --no-inline-config` over the two changed .ts files, exit 0. - Local-only families: `check:skill-frame-freshness` exit 0; `check:dev-prereqs` exit 1 because 35 of 106 workspace packages (the `ui/` tree among them; only `packages/**` was built here) have no dist in this container: a machine-state reading, not a finding of this diff (`packages/spec` dist is fresh). - CI: the seat reads the PR's check-runs. </details> ## Acceptance notes - **Same-class drift, larger than this card.** `getPublicConfig().features` serves at least nine more keys `AuthFeaturesConfigSchema` does not declare: `multiOrgEnabled`, `tenancyPosture`, `audiencePosture`, `degradedTenancy`, `oidcProvider`, `deviceAuthorization`, `admin`, `termsUrl`, `privacyUrl` (most are classified in `PUBLIC_AUTH_FEATURES` or `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS`, and several have readers, e.g. `deviceAuthorization` in the console's device-auth page). A contract parse strips them all. No pin ties the registry to the schema keys. Reported to the seat as one family card, not filed here. - `ui/packages/auth/src/types.ts` keeps a hand-written mirror of the features type (its `sso` comment says "wired", while the served value is "usable"). Not touched; noted. ## Verification See the readings above. The PR stays a draft; the seat runs the contract review, and the new public key name `sso` goes to the maintainer as `needs-user-decision`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the changed files locally; the repo-wide run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPzZFPzq2roY9uo1me)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c268b72 commit aee9c47

5 files changed

Lines changed: 73 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): declare `features.sso` on the `GET /api/v1/auth/config` response
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
`AuthFeaturesConfigSchema` gains an optional boolean `sso`. The server has served it for a long time, and the console login form reads it to decide whether to show "Sign in with SSO", but the contract did not declare it. A client that parsed the response through `GetAuthConfigResponseSchema` lost the flag, because a plain object schema strips keys it does not know.
11+
12+
- **What the value means.** `true` when enterprise SSO is usable: the SSO plugin is wired and at least one SSO provider is configured. `false` when SSO is off, or on with no provider yet. The `/auth/config` route answers this "usable" value, not the coarser "wired" flag, and falls back to "wired" when providers cannot be counted.
13+
- **How it differs from `ssoEnforced`.** `ssoEnforced` takes the password form away. `sso` only says whether the SSO entry exists.
14+
- **Absent** means not usable. The schema sets no default.
15+
16+
What to do: nothing. Every payload accepted before is accepted unchanged. A client that typed the response from `@objectstack/spec` (for example through `@objectstack/client`'s re-exported `AuthFeaturesConfig`) now sees `sso?: boolean`.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const result = AuthEndpointSchema.parse(data);
5959
| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (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, classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. |
6060
| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (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 the console release that restores this flag along with the login UI that reads it. |
6161
| **organization** | `boolean` | optional (default: `false`) | Multi-tenant organization support enabled |
62+
| **sso** | `boolean` | optional | Enterprise SSO sign-in usable: the domain-routed SSO plugin is wired AND at least one SSO provider is configured, so the login UI offers its "Sign in with SSO" entry; false when SSO is off, or on with no provider yet. The served value is that usable answer (isSsoUsable), not the coarse wired flag: GET /auth/config refines it before answering, and falls open to the wired flag when providers cannot be counted. Unlike ssoEnforced, which takes the password form away, this only says whether the SSO entry exists; absent means not usable |
6263
| **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) |
6364
| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) |
6465
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
@@ -159,7 +160,7 @@ This schema accepts one of the following structures:
159160
| :--- | :--- | :--- | :--- |
160161
| **emailPassword** | `{ enabled: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean }` | ✅ | Email/password authentication config |
161162
| **socialProviders** | `{ id: string; name: string; enabled: boolean; type?: Enum<'social' \| 'oidc'> }[]` | ✅ | Available social/OAuth providers |
162-
| **features** | `{ twoFactor?: boolean; organization?: boolean; ssoEnforced?: boolean; phoneNumber?: boolean; … }` | ✅ | Enabled authentication features |
163+
| **features** | `{ twoFactor?: boolean; organization?: boolean; sso?: boolean; ssoEnforced?: boolean; … }` | ✅ | Enabled authentication features |
163164

164165
### Nested Shape: `GetAuthConfigResponse.emailPassword`
165166

@@ -186,6 +187,7 @@ This schema accepts one of the following structures:
186187
| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (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, classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. |
187188
| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (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 the console release that restores this flag along with the login UI that reads it. |
188189
| **organization** | `boolean` | optional (default: `false`) | Multi-tenant organization support enabled |
190+
| **sso** | `boolean` | optional | Enterprise SSO sign-in usable: the domain-routed SSO plugin is wired AND at least one SSO provider is configured, so the login UI offers its "Sign in with SSO" entry; false when SSO is off, or on with no provider yet. The served value is that usable answer (isSsoUsable), not the coarse wired flag: GET /auth/config refines it before answering, and falls open to the wired flag when providers cannot be counted. Unlike ssoEnforced, which takes the password form away, this only says whether the SSO entry exists; absent means not usable |
189191
| **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) |
190192
| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) |
191193
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |

‎packages/spec/authorable-surface/api.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"api/AuthFeaturesConfig:passkeys [RETIRED]",
216216
"api/AuthFeaturesConfig:phoneNumber",
217217
"api/AuthFeaturesConfig:phoneNumberOtp",
218+
"api/AuthFeaturesConfig:sso",
218219
"api/AuthFeaturesConfig:ssoEnforced",
219220
"api/AuthFeaturesConfig:turnstileSiteKey",
220221
"api/AuthFeaturesConfig:twoFactor",

‎packages/spec/src/api/auth-endpoints.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AuthEndpointSchema,
88
AuthEndpointAliases,
99
AuthFeaturesConfigSchema,
10+
GetAuthConfigResponseSchema,
1011
EndpointMapping,
1112
getAuthEndpointUrl,
1213
} from './auth-endpoints.zod';
@@ -132,6 +133,49 @@ describe('AuthFeaturesConfig retired flags (ruled: stop advertising them)', () =
132133
});
133134
});
134135

136+
// `features.sso` is served (plugin-auth's `/config` route and the hono
137+
// adapter's, both refined to "usable") and read (the console LoginForm), so
138+
// the contract declares it: a parse keeps it instead of stripping it.
139+
describe('AuthFeaturesConfig.sso (served, now declared)', () => {
140+
const valid = { twoFactor: false, organization: true };
141+
142+
it.each([true, false])('keeps a served `sso: %s` through a parse', (sso) => {
143+
const parsed = AuthFeaturesConfigSchema.parse({ ...valid, sso });
144+
expect(parsed.sso).toBe(sso);
145+
});
146+
147+
it('keeps it through the whole GET /auth/config response parse', () => {
148+
const parsed = GetAuthConfigResponseSchema.parse({
149+
emailPassword: { enabled: true },
150+
socialProviders: [],
151+
features: { ...valid, sso: true, ssoEnforced: false },
152+
});
153+
expect(parsed.features.sso).toBe(true);
154+
expect(parsed.features.ssoEnforced).toBe(false);
155+
});
156+
157+
it('CONTROL: an absent `sso` stays absent (no default is invented)', () => {
158+
const parsed = AuthFeaturesConfigSchema.parse(valid);
159+
expect(parsed).not.toHaveProperty('sso');
160+
});
161+
162+
it.each([['yes'], [1], [null]])('refuses a non-boolean `sso` (%j)', (sso) => {
163+
const result = AuthFeaturesConfigSchema.safeParse({ ...valid, sso });
164+
expect(result.success).toBe(false);
165+
if (result.success) return;
166+
expect(result.error.issues).toEqual([
167+
expect.objectContaining({ code: 'invalid_type', expected: 'boolean', path: ['sso'] }),
168+
]);
169+
});
170+
171+
it('says it is the usable answer and how it differs from ssoEnforced', () => {
172+
const description = AuthFeaturesConfigSchema.shape.sso.description ?? '';
173+
expect(description).toMatch(/isSsoUsable/);
174+
expect(description).toMatch(/ssoEnforced/);
175+
expect(description).not.toMatch(/#\d{3,}/);
176+
});
177+
});
178+
135179
describe('AuthEndpointAliases', () => {
136180
it('should map common names to canonical endpoints', () => {
137181
expect(AuthEndpointAliases.login).toBe('/sign-in/email');

‎packages/spec/src/api/auth-endpoints.zod.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,15 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({
230230
passkeys: retiredKey(PASSKEYS_UNADVERTISED),
231231
magicLink: retiredKey(MAGIC_LINK_UNADVERTISED),
232232
organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'),
233+
// Served by plugin-auth (`getPublicConfig()`, refined by `isSsoUsable()` in
234+
// the `/config` route) and by the hono adapter's `/auth/config` route, which
235+
// applies the same refinement; read by the console LoginForm (#29344).
236+
sso: z.boolean().optional().describe(
237+
'Enterprise SSO sign-in usable: the domain-routed SSO plugin is wired AND at least one SSO provider is configured, so the login UI offers its "Sign in with SSO" entry; '
238+
+ 'false when SSO is off, or on with no provider yet. The served value is that usable answer (isSsoUsable), not the coarse wired flag: '
239+
+ 'GET /auth/config refines it before answering, and falls open to the wired flag when providers cannot be counted. '
240+
+ 'Unlike ssoEnforced, which takes the password form away, this only says whether the SSO entry exists; absent means not usable',
241+
),
233242
ssoEnforced: z.boolean().optional().describe(
234243
'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)',
235244
),

0 commit comments

Comments
 (0)