diff --git a/.changeset/29473-auth-config-features-served-keys.md b/.changeset/29473-auth-config-features-served-keys.md new file mode 100644 index 00000000000..30d3d3fbb1c --- /dev/null +++ b/.changeset/29473-auth-config-features-served-keys.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): declare the nine remaining served `features` keys on the `GET /api/v1/auth/config` response + +Clause-②: yes (widening) +Metadata-protocol: added + +`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. + +- **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). +- **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). +- None carries a default: a key the server does not send stays absent. +- 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. +- The development-only top-level `devSeedAdmin` hint stays undeclared on purpose: it carries the seeded admin's password and no production deployment serves it. + +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. diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index be25ae53a80..2a00bef7525 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -65,6 +65,15 @@ const result = AuthEndpointSchema.parse(data); | **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | | **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 | | **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 | +| **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 | +| **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) | +| **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 | +| **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 | +| **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 | +| **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 | +| **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 | +| **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 | +| **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 | --- @@ -193,6 +202,15 @@ This schema accepts one of the following structures: | **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | | **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 | | **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 | +| **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 | +| **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) | +| **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 | +| **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 | +| **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 | +| **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 | +| **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 | +| **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 | +| **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 | --- diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 685522133e1..4f8c546e060 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -209,14 +209,23 @@ "api/AuthEndpoint:signUpEmail", "api/AuthEndpoint:verifyEmail", "api/AuthFamilies:admin", + "api/AuthFeaturesConfig:admin", + "api/AuthFeaturesConfig:audiencePosture", "api/AuthFeaturesConfig:cloudManaged", + "api/AuthFeaturesConfig:degradedTenancy", + "api/AuthFeaturesConfig:deviceAuthorization", "api/AuthFeaturesConfig:magicLink [RETIRED]", + "api/AuthFeaturesConfig:multiOrgEnabled", + "api/AuthFeaturesConfig:oidcProvider", "api/AuthFeaturesConfig:organization", "api/AuthFeaturesConfig:passkeys [RETIRED]", "api/AuthFeaturesConfig:phoneNumber", "api/AuthFeaturesConfig:phoneNumberOtp", + "api/AuthFeaturesConfig:privacyUrl", "api/AuthFeaturesConfig:sso", "api/AuthFeaturesConfig:ssoEnforced", + "api/AuthFeaturesConfig:tenancyPosture", + "api/AuthFeaturesConfig:termsUrl", "api/AuthFeaturesConfig:turnstileSiteKey", "api/AuthFeaturesConfig:twoFactor", "api/AuthProviderInfo:enabled", diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index e95e1aa29e8..5ca56fc43f3 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -11,6 +11,11 @@ import { EndpointMapping, getAuthEndpointUrl, } from './auth-endpoints.zod'; +import { + PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS, + PUBLIC_AUTH_FEATURE_NAMES, + PUBLIC_AUTH_FEATURES_NOT_ADVERTISED, +} from '../kernel/public-auth-features'; describe('AuthEndpointPaths', () => { it('should define email/password authentication endpoints', () => { @@ -176,6 +181,100 @@ describe('AuthFeaturesConfig.sso (served, now declared)', () => { }); }); +// The registry-to-schema pin. plugin-auth's drift guard holds the served +// `features` keys equal to PUBLIC_AUTH_FEATURES plus the non-flag list; this +// holds those same keys equal to what the contract declares, so a key can no +// longer be served and classified while a contract parse strips it. +describe('AuthFeaturesConfig declares every classified served key', () => { + const shape = AuthFeaturesConfigSchema.shape; + const declared = Object.keys(shape); + const retired: readonly string[] = PUBLIC_AUTH_FEATURES_NOT_ADVERTISED; + const classified = [...PUBLIC_AUTH_FEATURE_NAMES, ...PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS]; + + it('declared keys, retired tombstones excepted, equal the flag registry plus the non-flag list', () => { + expect(declared.filter((key) => !retired.includes(key)).sort()).toEqual([...classified].sort()); + }); + + it('keeps a tombstone for every reserved-but-unadvertised key', () => { + for (const key of retired) expect(declared).toContain(key); + }); + + it.each([...PUBLIC_AUTH_FEATURE_NAMES])('declares the flag `%s` as an optional boolean', (name) => { + expect(AuthFeaturesConfigSchema.parse({ [name]: true })[name]).toBe(true); + expect(AuthFeaturesConfigSchema.parse({ [name]: false })[name]).toBe(false); + const refused = AuthFeaturesConfigSchema.safeParse({ [name]: 'yes' }); + expect(refused.success).toBe(false); + if (refused.success) return; + expect(refused.error.issues).toEqual([ + expect.objectContaining({ code: 'invalid_type', expected: 'boolean', path: [name] }), + ]); + }); + + it.each(declared.filter((key) => !retired.includes(key)))('`%s` carries a describe with no tracker id', (key) => { + const description = shape[key as keyof typeof shape].description ?? ''; + expect(description.length).toBeGreaterThan(0); + expect(description).not.toMatch(/#\d{3,}/); + }); + + // The served shape, every key `getPublicConfig()` can put in `features`. + const served = { + twoFactor: false, + organization: true, + multiOrgEnabled: true, + tenancyPosture: 'group', + audiencePosture: 'invite_only', + degradedTenancy: false, + oidcProvider: true, + sso: false, + ssoEnforced: false, + deviceAuthorization: true, + admin: true, + phoneNumber: false, + phoneNumberOtp: false, + cloudManaged: false, + termsUrl: 'https://objectstack.ai/terms', + privacyUrl: 'https://objectstack.ai/privacy', + turnstileSiteKey: '0x4AAAAAAA', + } as const; + + it('the served fixture names every classified key, so the parse below covers them all', () => { + expect(Object.keys(served).sort()).toEqual([...classified].sort()); + }); + + it('a whole GET /auth/config response keeps every served features key through a parse', () => { + const parsed = GetAuthConfigResponseSchema.parse({ + emailPassword: { enabled: true }, + socialProviders: [], + features: served, + }); + expect(parsed.features).toEqual(served); + }); + + it('CONTROL: absent new keys stay absent (no default is invented)', () => { + const parsed = AuthFeaturesConfigSchema.parse({ twoFactor: false, organization: true }); + expect(Object.keys(parsed).sort()).toEqual(['organization', 'twoFactor']); + }); + + it.each([ + ['tenancyPosture', 'multi'], + ['audiencePosture', 'public'], + ])('refuses an off-vocabulary `%s` (%j)', (key, value) => { + const result = AuthFeaturesConfigSchema.safeParse({ [key]: value }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual([expect.objectContaining({ code: 'invalid_value', path: [key] })]); + }); + + it.each(['termsUrl', 'privacyUrl'])('refuses a non-string `%s`', (key) => { + const result = AuthFeaturesConfigSchema.safeParse({ [key]: true }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual([ + expect.objectContaining({ code: 'invalid_type', expected: 'string', path: [key] }), + ]); + }); +}); + describe('AuthEndpointAliases', () => { it('should map common names to canonical endpoints', () => { expect(AuthEndpointAliases.login).toBe('/sign-in/email'); diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index fb278750ca1..667b56fddbe 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -25,6 +25,8 @@ import { z } from 'zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; +import { TenancyPostureSchema } from '../security/tenancy-posture'; +import { AUDIENCE_POSTURES } from '../system/auth-config.zod'; export const AuthEndpointPaths = { // Email/Password Authentication signInEmail: '/sign-in/email', @@ -254,6 +256,45 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ turnstileSiteKey: z.string().optional().describe( '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', ), + // The keys below are served by plugin-auth `getPublicConfig()` (auth-manager.ts, + // the `features` literal) and classified in PUBLIC_AUTH_FEATURES (flags) or + // PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (values); the registry-to-schema pin in + // auth-endpoints.test.ts keeps the three key sets equal (#29473). + multiOrgEnabled: z.boolean().optional().describe( + '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', + ), + tenancyPosture: TenancyPostureSchema.optional().describe( + '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)', + ), + audiencePosture: z.enum(AUDIENCE_POSTURES).optional().describe( + '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', + ), + degradedTenancy: z.boolean().optional().describe( + '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', + ), + oidcProvider: z.boolean().optional().describe( + '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', + ), + deviceAuthorization: z.boolean().optional().describe( + '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', + ), + admin: z.boolean().optional().describe( + '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', + ), + termsUrl: z.string().optional().describe( + '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', + ), + privacyUrl: z.string().optional().describe( + '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', + ), })); /** @@ -261,6 +302,13 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ * * Returns the public authentication configuration that the frontend * can use to render appropriate login UI (social provider buttons, etc.) + * + * `devSeedAdmin` is deliberately NOT declared here. plugin-auth adds it at the + * top level only under NODE_ENV=development, while the auto-seeded admin still + * signs in with its default password, and it carries that email and password. + * A published response contract would advertise a credential-bearing field that + * no production deployment serves; its one reader, the console LoginPage, reads + * the raw response and never parses it through this schema. */ export const GetAuthConfigResponseSchema = lazySchema(() => z.object({ emailPassword: EmailPasswordConfigPublicSchema.describe('Email/password authentication config'),