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
18 changes: 18 additions & 0 deletions .changeset/29473-auth-config-features-served-keys.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions content/docs/references/api/auth-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


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


---
Expand Down
9 changes: 9 additions & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions packages/spec/src/api/auth-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading