Skip to content

Commit 4b06a54

Browse files
feat(spec): declare the nine served features keys on the auth config response and pin the registry to the schema (#29583)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8TGJRGUFpQY9Kxs3zg4DBsC)_ Fixes #29473 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — `git grep` under `skills/` finds no `auth/config` reference, and the one hit for any of the nine key names is `skills/objectui/guides/auth-permissions.md` using `features.multiOrgEnabled` as an expression example, which stays true; the API reference page is regenerated in this diff ## 维护者速读 - 改了什么:spec 的 `GET /api/v1/auth/config` 响应契约 `AuthFeaturesConfigSchema` 新增九个可选键(服务端早就在发、控制台早就在读,只是契约没声明),并加一条测试把「已分类的键」与「契约声明的键」钉成同一集合。 - 为什么改:按契约解析响应的客户端会把这九个键静默丢掉;以前每漏一个键就要单开一张卡(`sso`、`cloudManaged` 都是这样),这条钉子让下一个漏声明的键在 CI 直接变红。 - 风险与回滚:纯加宽,以前能解析的响应原样解析;九个键都无默认值,不发就仍不存在;运行时代码一行未动;回滚即撤掉此 PR。 - 先看:`packages/spec/src/api/auth-endpoints.zod.ts`(九个新键与说明、`devSeedAdmin` 不声明的理由)、`packages/spec/src/api/auth-endpoints.test.ts`(钉子测试)、`.changeset/29473-auth-config-features-served-keys.md`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/api/auth-endpoints.test.ts`,预期 69 个测试全部通过。 - 需要你做的:确认下列九个新公开键名(均为服务端现有键名,未改名):`multiOrgEnabled`、`degradedTenancy`、`oidcProvider`、`deviceAuthorization`、`admin`、`tenancyPosture`、`audiencePosture`、`termsUrl`、`privacyUrl`;并确认 `devSeedAdmin`(仅开发环境、含明文密码)不进契约。其余席位落地。 ## What changes - **Nine optional keys on `AuthFeaturesConfigSchema`** (`packages/spec/src/api/auth-endpoints.zod.ts`), each with a describe that says what the producer means and what absent means. None carries a default, so a key the server does not send stays absent. - Booleans, all classified in `PUBLIC_AUTH_FEATURES`: `multiOrgEnabled` (an organization wall is enforced; default-on, so absent reads as on), `degradedTenancy` (isolation requested but inactive; a status signal), `oidcProvider` (the embedded OIDC authorization server is wired; default-on), `deviceAuthorization` (device-code CLI login wired), `admin` (admin plugin wired, forced on by SCIM). Each describe's "absent" sentence follows the registry entry's `semantics`. - Values, all in `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS`: `tenancyPosture` reuses `TenancyPostureSchema` (`security/tenancy-posture.ts`, the vocabulary the producer's `TenancyPosture` type is), `audiencePosture` reuses `AUDIENCE_POSTURES` (`system/auth-config.zod.ts`; the producer reports an off-vocabulary configured value as `invite_only`), and `termsUrl` / `privacyUrl` are plain strings, because the producer passes the trimmed `OS_TERMS_URL` / `OS_PRIVACY_URL` value through without URL validation (a relative path is served as-is). Both imported modules import only `zod` and `lazySchema`, so no cycle. - **`devSeedAdmin` is ruled out as dev-only, not declared.** plugin-auth adds it at the top level of the response only under `NODE_ENV=development`, while the auto-seeded admin still signs in with the default password, and it carries `{ email, password }`. Declaring it would put a credential-bearing field on a published response contract (and its generated reference page) that no production deployment serves. Its one reader, `ui/apps/console/src/pages/auth/LoginPage.tsx`, casts the raw response and never parses it through the schema, so nothing is lost. The reason sits in a TSDoc block on `GetAuthConfigResponseSchema`. - **The registry-to-schema pin** (`packages/spec/src/api/auth-endpoints.test.ts`, block `AuthFeaturesConfig declares every classified served key`): the schema's declared keys, minus the retired `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` tombstones (`passkeys`, `magicLink`), equal `PUBLIC_AUTH_FEATURE_NAMES` plus `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS`, exactly, in both directions. With plugin-auth's existing drift guard (served keys equal the registry), the served keys and the contract keys are now held equal through the registry. It is a test, not a gate. - Regenerated: `packages/spec/authorable-surface/api.json` (nine `api/AuthFeaturesConfig:*` rows, written by the build) and `content/docs/references/api/auth-endpoints.mdx` (`gen:docs`, the one artifact `check:generated` named stale). `api-surface` did not move: no export was added. - Changeset `.changeset/29473-auth-config-features-served-keys.md`: `@objectstack/spec` `minor`, both declaration lines. **Producer readings** (origin/main `390bd2ae`, `packages/plugins/plugin-auth/src/auth-manager.ts` `getPublicConfig()`): the `features` literal serves `twoFactor`, `organization`, `multiOrgEnabled`, `tenancyPosture`, `audiencePosture`, `degradedTenancy`, `oidcProvider`, `sso`, `ssoEnforced`, `deviceAuthorization`, `admin`, `phoneNumber`, `phoneNumberOtp`, `cloudManaged`, and conditionally `termsUrl`, `privacyUrl`, `turnstileSiteKey`: 17 keys, exactly the registry's 12 flags plus the 5 non-flag keys, so the served set, the registry and (after this PR) the schema agree. `devSeedAdmin` sits beside `features`, not in it. ## Why `GET /api/v1/auth/config` serves these nine keys and the console reads several of them (for example `deviceAuthorization` in `ui/apps/console/src/pages/auth/DeviceAuthPage.tsx`), but the contract did not declare them, so a client parsing through `GetAuthConfigResponseSchema` lost all nine: a plain object schema strips unknown keys. Nothing tied the registry to the schema, which is how `sso` and `cloudManaged` each needed their own card. **Why `Metadata-protocol: added`, not the claim's `none`.** The claim recorded `none` ("a wire response, not authorable metadata") and asked the dev to confirm against AGENTS.md Post-Task Checklist step 3. Step 3 defines `added` as "a new key, accepted value or metadata type", and says that where the ratchets can see a protocol change, they decide: `packages/spec/authorable-surface/` records this response schema (`api/AuthFeaturesConfig:*`, including the `sso` row PR #29455 added under `added`), and this diff adds nine rows to `api.json`. Measured offline with this diff and a PR body that differs only in that line: `node scripts/check-changeset-no-major.mjs --base origin/main --event` exits 1 under `none` ("metadata-protocol declares `none` while an authorable-surface or authorable-defaults baseline moved … Moved: packages/spec/authorable-surface/api.json") and exits 0 under `added` ("LEVEL AXIS … `yes (widening)` and metadata-protocol `added`"). So `added`, with `@objectstack/spec` at `minor`, is the only declaration the gate accepts, and it matches the `sso` precedent on the same schema. ## Tests `packages/spec/src/api/auth-endpoints.test.ts`, new block, 38 cases: - the declared-keys set (retired tombstones excepted) equals the registry plus the non-flag list, and each retired key keeps its tombstone; - each of the 12 registry flags parses as `true` and `false` and refuses `'yes'` with exactly one `invalid_type` issue at its own path, expected `boolean`; - each of the 17 declared keys carries a describe with no tracker id; - a served-shape fixture naming all 17 keys (checked against the classified list) survives a whole `GetAuthConfigResponseSchema` parse unchanged; - CONTROL: absent new keys stay absent (no default is invented); - an off-vocabulary `tenancyPosture` / `audiencePosture` is refused with one `invalid_value` issue at its path; a non-string `termsUrl` / `privacyUrl` is refused with one `invalid_type` issue. <details><summary>Local readings (head d68dd29)</summary> - Build and regen: `pnpm --filter @objectstack/spec build` under os-verify-lock, VERDICT 0, then `check:generated` exit 1 naming one stale artifact (`content/docs/references/**`); `gen:docs` VERDICT 0; `check:generated` again exit 0 ("All 14 generated artifacts are up to date"). - `pnpm --filter @objectstack/spec test`: VERDICT command-exit 0, Test Files 794 passed (794), Tests 23587 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: VERDICT 0 (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`, which compiles the test layer). - Consumer: `turbo run build --concurrency=2 --filter=@objectstack/client...` then `pnpm --filter @objectstack/client typecheck` (it re-exports `AuthFeaturesConfig` / `GetAuthConfigResponse`): VERDICT 0. Reverse check that it reads the rebuilt declarations: a probe in `packages/client/src` typing `{ deviceAuthorization: true, tenancyPosture: 'group', audiencePosture: 'open', termsUrl: 'x' }` as `AuthFeaturesConfig` compiles (exit 0), and `{ deviceAuthorization: 'yes' }` is refused TS2322 (exit 2). Probe deleted. - No other workspace consumer: `git grep` for `AuthFeaturesConfig` / `GetAuthConfigResponse` outside `packages/spec` finds `packages/client/src/index.ts` (re-export) and `ui/packages/auth/src/types.ts` (a hand-written mirror that does not import the spec type). - Ablation: `deviceAuthorization` renamed to `deviceAuthorizationAblated` in the schema with `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `b237957a` to `595c0b73`): `auth-endpoints.test.ts` exit 1, 3 failed (the set-equality pin, that flag's boolean case, the whole-response parse). Restored: blob equal to HEAD `b237957a`, `git diff HEAD` empty. - Level axis, offline: see Why (`none` exit 1, `added` exit 0). - Scoped lint: `pnpm exec eslint --no-inline-config` over the two changed .ts files, exit 0. `check-nul-bytes` exit 0. - Dogfood battery: n/a, no `!` in the title and no `(narrowing)` arm. </details> <details><summary>Gates (dispatch-gates --commands)</summary> - At head `d68dd29f` (after merging `origin/main` `208d3e2e`): 103 derived commands, all 103 exit 0, each run with stdin closed and its exit captured before any pipe. Before them, `turbo run build --concurrency=2 --filter='./packages/**'` under os-verify-lock, VERDICT 0 (78 tasks). - `dispatch-gates --ran`: 103 derived, 103 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. - At the earlier head `7d5a57a6` the same 103 ran; three first exited 3 (PREREQUISITE NOT MET) and passed once their inputs existed: `check-plugin-teardown-shape.mjs --self-test` after `git fetch --depth=1` of its pinned fixture commit `621a4876`, `check:skill-examples` after building `@objectstack/client-react`, and `check:dual-build-cjs-loads` after building every package. - 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`); the same with only the protocol line set to `none` exit 1 (see Why); `check-partof-closing-keyword.mjs` 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 - `ui/packages/auth/src/types.ts` (lines 284 to 289) documents `features.sso` as "wired; when falsy the `/sign-in/sso` route isn't mounted", while the served value is "usable", so a served `false` can mean wired with no provider yet and the route mounted. The type (`sso?: boolean`) is right. Prose only, for the ui lane; not touched here (this diff does not edit `ui/`). The same hand-written mirror is also where the nine keys would be typed for the console, if the ui lane wants them typed. - `tenancyPosture` and `audiencePosture` are closed enums. A plain object schema fails the whole parse on an unknown enum member, so a server that later adds a fourth posture fails an older client's `GetAuthConfigResponseSchema` parse instead of being stripped. That is the contract-first reading (both vocabularies are the producer's own types today); noted for the contract review. - `termsUrl` / `privacyUrl` are not URL-validated, matching the producer, which serves any trimmed non-empty `OS_TERMS_URL` / `OS_PRIVACY_URL` value. ## Verification See the readings above. The PR stays a draft; the seat runs the contract review, and the nine key names plus the `devSeedAdmin` ruling go to the maintainer for the nod. ## 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. ## Maintainer approval Warren approved the nine served `features` keys (`multiOrgEnabled`, `degradedTenancy`, `oidcProvider`, `deviceAuthorization`, `admin`, `tenancyPosture`, `audiencePosture`, `termsUrl`, `privacyUrl`) as named, with `devSeedAdmin` left undeclared: 「全部批准」 on the decision card 「六个命名/措辞全部按推荐批准吗?」 in the project thread "Decide pending spec name approvals", 2026-11-03 00:08Z. Recorded on this PR in seat comment 6591810483. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbaRoocSDkgqwRAGseR)_ <!-- signed: domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 744211f commit 4b06a54

5 files changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): declare the nine remaining served `features` keys on the `GET /api/v1/auth/config` response
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
`AuthFeaturesConfigSchema` gains nine optional keys that the server already serves in `features` and the console already reads. The contract did not declare them, so a client that parsed the response through `GetAuthConfigResponseSchema` lost all nine, because a plain object schema strips keys it does not know.
11+
12+
- **Flags (booleans):** `multiOrgEnabled` (an organization wall is enforced; default-on, so absent reads as on), `degradedTenancy` (isolation was asked for but is not active), `oidcProvider` (the embedded OIDC authorization server is wired; default-on), `deviceAuthorization` (the device-code CLI login is wired), `admin` (the admin plugin is wired, forced on by SCIM).
13+
- **Values:** `tenancyPosture` (`single` | `group` | `isolated`, the same vocabulary as `TenancyPostureSchema`), `audiencePosture` (`invite_only` | `email_domain` | `open`, the same vocabulary as `AUDIENCE_POSTURES`), `termsUrl` and `privacyUrl` (the legal links under the login card; absent when the operator suppressed them).
14+
- None carries a default: a key the server does not send stays absent.
15+
- A new test holds the schema's declared keys equal to `PUBLIC_AUTH_FEATURES` plus `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS` (the retired `passkeys` / `magicLink` tombstones excepted), so the next served and classified key cannot be left undeclared.
16+
- The development-only top-level `devSeedAdmin` hint stays undeclared on purpose: it carries the seeded admin's password and no production deployment serves it.
17+
18+
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 the nine keys as optional.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ const result = AuthEndpointSchema.parse(data);
6565
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
6666
| **cloudManaged** | `boolean` | optional | Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed |
6767
| **turnstileSiteKey** | `string` | optional | Public Cloudflare Turnstile site key, present only while the opt-in sign-up challenge is on: the sign-up form renders the widget and sends its token in the x-captcha-response header; absent means no challenge |
68+
| **multiOrgEnabled** | `boolean` | optional | Multi-organization capability: true when the effective tenancy posture enforces an organization wall (group or isolated), the same answer the create-organization door gives; false on a single posture, and on a degraded deployment that asked for isolation but has none. The console offers creating, switching and leaving organizations only when it is on; the flag is default-on, so absent reads as on |
69+
| **tenancyPosture** | `Enum<'single' \| 'group' \| 'isolated'>` | optional | Which tenancy posture is in force (single, group or isolated): the effective posture, from the same derivation as multiOrgEnabled. A value, not a gate: multiOrgEnabled stays the capability flag, while this tells the console how to render organization context (under group the organization switcher picks the write target and reads span every organization the member belongs to) |
70+
| **audiencePosture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional | Who may self-register into this environment (invite_only, email_domain or open): the posture the sign-up path enforces, reported as invite_only when the configured value is off-vocabulary. A value, not a gate: emailPassword.disableSignUp stays the boolean that hides the sign-up form, while this lets the login page say that registration is by invitation instead of offering a form the server refuses |
71+
| **degradedTenancy** | `boolean` | optional | Degraded tenancy: true when multi-organization isolation was requested but is not active (the deployment booted only because OS_ALLOW_DEGRADED_TENANCY=1), so no engine enforces organization boundaries; the console can raise an operator warning off it. A status signal, not a capability gate; absent means not degraded |
72+
| **oidcProvider** | `boolean` | optional | Embedded OIDC authorization server wired, from the same decision that mounts it, so OAuth applications can be registered and managed; on by default alongside the MCP surface. It says nothing about sign-in providers, which socialProviders lists; the flag is default-on, so absent reads as on |
73+
| **deviceAuthorization** | `boolean` | optional | Device authorization (browser-approved CLI login) wired, so the device-code endpoints answer; when false the console device-approval page says device authorization is not enabled instead of calling them. Absent means not wired |
74+
| **admin** | `boolean` | optional | Admin plugin wired (forced on whenever SCIM is enabled), so user administration (creating, banning, unlocking and impersonating users, setting their passwords) is available; the console shows those actions only when it is true. Absent means not wired |
75+
| **termsUrl** | `string` | optional | Terms-of-service link shown beneath the login and register cards: the trimmed OS_TERMS_URL value when set, otherwise the public ObjectStack terms page; absent when the operator set OS_TERMS_URL to an empty or blank (whitespace-only) value to suppress the link |
76+
| **privacyUrl** | `string` | optional | Privacy-policy link shown beneath the login and register cards: the trimmed OS_PRIVACY_URL value when set, otherwise the public ObjectStack privacy page; absent when the operator set OS_PRIVACY_URL to an empty or blank (whitespace-only) value to suppress the link |
6877

6978

7079
---
@@ -193,6 +202,15 @@ This schema accepts one of the following structures:
193202
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
194203
| **cloudManaged** | `boolean` | optional | Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed |
195204
| **turnstileSiteKey** | `string` | optional | Public Cloudflare Turnstile site key, present only while the opt-in sign-up challenge is on: the sign-up form renders the widget and sends its token in the x-captcha-response header; absent means no challenge |
205+
| **multiOrgEnabled** | `boolean` | optional | Multi-organization capability: true when the effective tenancy posture enforces an organization wall (group or isolated), the same answer the create-organization door gives; false on a single posture, and on a degraded deployment that asked for isolation but has none. The console offers creating, switching and leaving organizations only when it is on; the flag is default-on, so absent reads as on |
206+
| **tenancyPosture** | `Enum<'single' \| 'group' \| 'isolated'>` | optional | Which tenancy posture is in force (single, group or isolated): the effective posture, from the same derivation as multiOrgEnabled. A value, not a gate: multiOrgEnabled stays the capability flag, while this tells the console how to render organization context (under group the organization switcher picks the write target and reads span every organization the member belongs to) |
207+
| **audiencePosture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional | Who may self-register into this environment (invite_only, email_domain or open): the posture the sign-up path enforces, reported as invite_only when the configured value is off-vocabulary. A value, not a gate: emailPassword.disableSignUp stays the boolean that hides the sign-up form, while this lets the login page say that registration is by invitation instead of offering a form the server refuses |
208+
| **degradedTenancy** | `boolean` | optional | Degraded tenancy: true when multi-organization isolation was requested but is not active (the deployment booted only because OS_ALLOW_DEGRADED_TENANCY=1), so no engine enforces organization boundaries; the console can raise an operator warning off it. A status signal, not a capability gate; absent means not degraded |
209+
| **oidcProvider** | `boolean` | optional | Embedded OIDC authorization server wired, from the same decision that mounts it, so OAuth applications can be registered and managed; on by default alongside the MCP surface. It says nothing about sign-in providers, which socialProviders lists; the flag is default-on, so absent reads as on |
210+
| **deviceAuthorization** | `boolean` | optional | Device authorization (browser-approved CLI login) wired, so the device-code endpoints answer; when false the console device-approval page says device authorization is not enabled instead of calling them. Absent means not wired |
211+
| **admin** | `boolean` | optional | Admin plugin wired (forced on whenever SCIM is enabled), so user administration (creating, banning, unlocking and impersonating users, setting their passwords) is available; the console shows those actions only when it is true. Absent means not wired |
212+
| **termsUrl** | `string` | optional | Terms-of-service link shown beneath the login and register cards: the trimmed OS_TERMS_URL value when set, otherwise the public ObjectStack terms page; absent when the operator set OS_TERMS_URL to an empty or blank (whitespace-only) value to suppress the link |
213+
| **privacyUrl** | `string` | optional | Privacy-policy link shown beneath the login and register cards: the trimmed OS_PRIVACY_URL value when set, otherwise the public ObjectStack privacy page; absent when the operator set OS_PRIVACY_URL to an empty or blank (whitespace-only) value to suppress the link |
196214

197215

198216
---

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,23 @@
209209
"api/AuthEndpoint:signUpEmail",
210210
"api/AuthEndpoint:verifyEmail",
211211
"api/AuthFamilies:admin",
212+
"api/AuthFeaturesConfig:admin",
213+
"api/AuthFeaturesConfig:audiencePosture",
212214
"api/AuthFeaturesConfig:cloudManaged",
215+
"api/AuthFeaturesConfig:degradedTenancy",
216+
"api/AuthFeaturesConfig:deviceAuthorization",
213217
"api/AuthFeaturesConfig:magicLink [RETIRED]",
218+
"api/AuthFeaturesConfig:multiOrgEnabled",
219+
"api/AuthFeaturesConfig:oidcProvider",
214220
"api/AuthFeaturesConfig:organization",
215221
"api/AuthFeaturesConfig:passkeys [RETIRED]",
216222
"api/AuthFeaturesConfig:phoneNumber",
217223
"api/AuthFeaturesConfig:phoneNumberOtp",
224+
"api/AuthFeaturesConfig:privacyUrl",
218225
"api/AuthFeaturesConfig:sso",
219226
"api/AuthFeaturesConfig:ssoEnforced",
227+
"api/AuthFeaturesConfig:tenancyPosture",
228+
"api/AuthFeaturesConfig:termsUrl",
220229
"api/AuthFeaturesConfig:turnstileSiteKey",
221230
"api/AuthFeaturesConfig:twoFactor",
222231
"api/AuthProviderInfo:enabled",

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
EndpointMapping,
1212
getAuthEndpointUrl,
1313
} from './auth-endpoints.zod';
14+
import {
15+
PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS,
16+
PUBLIC_AUTH_FEATURE_NAMES,
17+
PUBLIC_AUTH_FEATURES_NOT_ADVERTISED,
18+
} from '../kernel/public-auth-features';
1419

1520
describe('AuthEndpointPaths', () => {
1621
it('should define email/password authentication endpoints', () => {
@@ -176,6 +181,100 @@ describe('AuthFeaturesConfig.sso (served, now declared)', () => {
176181
});
177182
});
178183

184+
// The registry-to-schema pin. plugin-auth's drift guard holds the served
185+
// `features` keys equal to PUBLIC_AUTH_FEATURES plus the non-flag list; this
186+
// holds those same keys equal to what the contract declares, so a key can no
187+
// longer be served and classified while a contract parse strips it.
188+
describe('AuthFeaturesConfig declares every classified served key', () => {
189+
const shape = AuthFeaturesConfigSchema.shape;
190+
const declared = Object.keys(shape);
191+
const retired: readonly string[] = PUBLIC_AUTH_FEATURES_NOT_ADVERTISED;
192+
const classified = [...PUBLIC_AUTH_FEATURE_NAMES, ...PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS];
193+
194+
it('declared keys, retired tombstones excepted, equal the flag registry plus the non-flag list', () => {
195+
expect(declared.filter((key) => !retired.includes(key)).sort()).toEqual([...classified].sort());
196+
});
197+
198+
it('keeps a tombstone for every reserved-but-unadvertised key', () => {
199+
for (const key of retired) expect(declared).toContain(key);
200+
});
201+
202+
it.each([...PUBLIC_AUTH_FEATURE_NAMES])('declares the flag `%s` as an optional boolean', (name) => {
203+
expect(AuthFeaturesConfigSchema.parse({ [name]: true })[name]).toBe(true);
204+
expect(AuthFeaturesConfigSchema.parse({ [name]: false })[name]).toBe(false);
205+
const refused = AuthFeaturesConfigSchema.safeParse({ [name]: 'yes' });
206+
expect(refused.success).toBe(false);
207+
if (refused.success) return;
208+
expect(refused.error.issues).toEqual([
209+
expect.objectContaining({ code: 'invalid_type', expected: 'boolean', path: [name] }),
210+
]);
211+
});
212+
213+
it.each(declared.filter((key) => !retired.includes(key)))('`%s` carries a describe with no tracker id', (key) => {
214+
const description = shape[key as keyof typeof shape].description ?? '';
215+
expect(description.length).toBeGreaterThan(0);
216+
expect(description).not.toMatch(/#\d{3,}/);
217+
});
218+
219+
// The served shape, every key `getPublicConfig()` can put in `features`.
220+
const served = {
221+
twoFactor: false,
222+
organization: true,
223+
multiOrgEnabled: true,
224+
tenancyPosture: 'group',
225+
audiencePosture: 'invite_only',
226+
degradedTenancy: false,
227+
oidcProvider: true,
228+
sso: false,
229+
ssoEnforced: false,
230+
deviceAuthorization: true,
231+
admin: true,
232+
phoneNumber: false,
233+
phoneNumberOtp: false,
234+
cloudManaged: false,
235+
termsUrl: 'https://objectstack.ai/terms',
236+
privacyUrl: 'https://objectstack.ai/privacy',
237+
turnstileSiteKey: '0x4AAAAAAA',
238+
} as const;
239+
240+
it('the served fixture names every classified key, so the parse below covers them all', () => {
241+
expect(Object.keys(served).sort()).toEqual([...classified].sort());
242+
});
243+
244+
it('a whole GET /auth/config response keeps every served features key through a parse', () => {
245+
const parsed = GetAuthConfigResponseSchema.parse({
246+
emailPassword: { enabled: true },
247+
socialProviders: [],
248+
features: served,
249+
});
250+
expect(parsed.features).toEqual(served);
251+
});
252+
253+
it('CONTROL: absent new keys stay absent (no default is invented)', () => {
254+
const parsed = AuthFeaturesConfigSchema.parse({ twoFactor: false, organization: true });
255+
expect(Object.keys(parsed).sort()).toEqual(['organization', 'twoFactor']);
256+
});
257+
258+
it.each([
259+
['tenancyPosture', 'multi'],
260+
['audiencePosture', 'public'],
261+
])('refuses an off-vocabulary `%s` (%j)', (key, value) => {
262+
const result = AuthFeaturesConfigSchema.safeParse({ [key]: value });
263+
expect(result.success).toBe(false);
264+
if (result.success) return;
265+
expect(result.error.issues).toEqual([expect.objectContaining({ code: 'invalid_value', path: [key] })]);
266+
});
267+
268+
it.each(['termsUrl', 'privacyUrl'])('refuses a non-string `%s`', (key) => {
269+
const result = AuthFeaturesConfigSchema.safeParse({ [key]: true });
270+
expect(result.success).toBe(false);
271+
if (result.success) return;
272+
expect(result.error.issues).toEqual([
273+
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: [key] }),
274+
]);
275+
});
276+
});
277+
179278
describe('AuthEndpointAliases', () => {
180279
it('should map common names to canonical endpoints', () => {
181280
expect(AuthEndpointAliases.login).toBe('/sign-in/email');

0 commit comments

Comments
 (0)