diff --git a/.changeset/feature-gate-registry-and-requires-feature.md b/.changeset/feature-gate-registry-and-requires-feature.md new file mode 100644 index 0000000000..0a3cfba8c5 --- /dev/null +++ b/.changeset/feature-gate-registry-and-requires-feature.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +'@objectstack/platform-objects': patch +--- + +**Every feature-gated capability is now UI-gated, guardrailed by a flag registry and a declarative `requiresFeature` annotation (#2874, generalizing the create-user phone fix #2871).** + +`@objectstack/spec/kernel` gains `PUBLIC_AUTH_FEATURES` — a classification registry for all 13 boolean flags served at `/api/v1/auth/config`: consumption surface (crud/login/status), default semantics (opt-in `== true` vs default-on `!= false`), and the gated spec inputs or an exemption reason. A plugin-auth drift test pins the served key set to the registry, and a platform-objects completeness guard pins the registry to the actual gates in both directions. + +`ActionSchema`/`ActionParamSchema` gain `requiresFeature: ''` (enum-checked), lowered at parse time into the canonical `visible` CEL predicate per the flag's registered semantics, AND-composed with any explicit `visible`, and stripped from the output — renderers and lint see only `visible`, so objectui needs no changes. All 22 hand-written `features.*` gates migrated (behavior-locked by an exact-string matrix test), and the audit gated 17 previously naked capability-dependent actions: the six `sys_user` platform-admin actions, six 2FA actions, and five `sys_oauth_application` actions now hide when their plugin is off instead of rendering buttons that 404. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 58be340926..3d6d5af3e4 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -189,6 +189,7 @@ interface Action { // Access visible?: ExpressionInput; // Visibility predicate (CEL) + requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL) // Bulk @@ -291,6 +292,27 @@ Prefer the `record.` form: it reads unambiguously and resolves identicall This is a narrower scope than [record-alert](/docs/protocol/objectui/record-alert) conditions (which also expose `os.user` / `os.org` / `os.env`) — action predicates see the record only. +#### Capability gates: `requiresFeature` + +When an action (or param) only works with an opt-in auth capability behind it — the better-auth `admin` plugin, `phoneNumber`, `twoFactor`, … — gate it with **`requiresFeature: ''`** instead of a hand-written `features.*` predicate. The flag names the public feature flag served at `/api/v1/auth/config` (see `PUBLIC_AUTH_FEATURES` in `@objectstack/spec/kernel`); at parse time the schema lowers it into the canonical `visible` predicate — `features.X == true` for opt-in flags, `features.X != false` for default-on flags — AND-composing with any explicit `visible` you also declare, then strips itself from the output. An unknown flag name fails the parse. + +```yaml +# Hidden entirely when the admin plugin is off (instead of a button that 404s) +name: ban_user +label: Ban User +type: api +target: /api/v1/auth/admin/ban-user +requiresFeature: admin + +# Composes with a residual row predicate: +# (!record.disabled) && features.oidcProvider != false +name: disable_oauth_application +visible: "!record.disabled" +requiresFeature: oidcProvider +``` + +This is how the platform keeps "form follows plugin" (#2874): the UI never advertises an input the default backend would reject. + ## Confirmation & Feedback Actions express confirmation and feedback through dedicated string fields — there is no nested confirm-dialog object with `requireTyping`/`confirmLabel`. @@ -375,7 +397,7 @@ params: - { label: High, value: high } ``` -`ActionParam` fields: `name`, `field`, `objectOverride`, `label`, `type`, `required` (default `false`), `options`, `placeholder`, `helpText`, `defaultValue`, `defaultFromRow`. +`ActionParam` fields: `name`, `field`, `objectOverride`, `label`, `type`, `required` (default `false`), `options`, `placeholder`, `helpText`, `defaultValue`, `defaultFromRow`, `visible` (CEL predicate — the dialog omits the param when false), `requiresFeature` (capability gate, lowered into `visible` — see [Capability gates](#capability-gates-requiresfeature)). ## AI Tool Exposure (ADR-0011) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 86d676c680..a4dc70fb65 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -48,8 +48,8 @@ to the field name and is used as the request-body key). ## TypeScript Usage ```typescript -import { ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui'; -import type { ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui'; +import { ActionAi, ActionLocation, ActionType } from '@objectstack/spec/ui'; +import type { ActionAi, ActionLocation, ActionType } from '@objectstack/spec/ui'; // Validate data const result = ActionAi.parse(data); @@ -86,27 +86,6 @@ const result = ActionAi.parse(data); * `global_nav` ---- - -## ActionParam - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | optional | | -| **field** | `string` | optional | Snake case identifier (lowercase with underscores only) | -| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | -| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) | -| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | optional | | -| **required** | `boolean` | ✅ | | -| **options** | `Object[]` | optional | | -| **placeholder** | `string` | optional | | -| **helpText** | `string` | optional | | -| **defaultValue** | `any` | optional | | -| **defaultFromRow** | `boolean` | optional | | - - --- ## ActionType diff --git a/packages/platform-objects/src/feature-gate-guard.test.ts b/packages/platform-objects/src/feature-gate-guard.test.ts new file mode 100644 index 0000000000..9d011e3635 --- /dev/null +++ b/packages/platform-objects/src/feature-gate-guard.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Completeness guard (#2874 P2): the PUBLIC_AUTH_FEATURES registry + * (`@objectstack/spec/kernel`) and the feature gates actually carried by the + * platform objects must stay in lockstep, in BOTH directions: + * + * 1. Forward (registry → objects): every `gatedInputs` path must resolve to a + * real action/param whose lowered `visible` predicate carries the flag's + * gate. Removing a `requiresFeature` annotation (or the whole input) + * without updating the registry turns this red. + * 2. Reverse (objects → registry): every `features.` reference inside + * any action/param `visible` predicate must name a registry flag AND be + * booked in that flag's `gatedInputs`. Adding a gate without registry + * bookkeeping turns this red. + * + * Scope notes: objects that moved to capability plugins per ADR-0029 K2 + * (plugin-security / plugin-sharing / plugin-audit / service-realtime / + * plugin-webhooks) are outside this package and this guard; none of them + * reference `features.*` today. Object FIELDS (`visibleWhen`) carry no + * feature gates today and are not walked. Per the issue's explicit non-goal, + * this guard cannot catch a brand-new capability-dependent input that was + * never annotated at all — that ground truth lives in plugin runtime + * behavior. + */ + +import { describe, expect, it } from 'vitest'; +import { + PUBLIC_AUTH_FEATURES, + featureGatePredicate, + type PublicAuthFeatureName, +} from '@objectstack/spec/kernel'; +import * as identity from './identity/index.js'; +import * as metadata from './metadata/index.js'; +import * as system from './system/index.js'; + +type AnyParam = { name?: string; field?: string; visible?: { source?: string } }; +type AnyAction = { name?: string; visible?: { source?: string }; params?: AnyParam[] }; +type AnyObject = { name?: string; actions?: AnyAction[] }; + +// Every exported platform object, keyed by machine name (object.name). +const objectsByName = new Map(); +for (const mod of [identity, metadata, system]) { + for (const value of Object.values(mod)) { + const obj = value as AnyObject; + if (obj && typeof obj === 'object' && typeof obj.name === 'string' && obj.name.startsWith('sys_')) { + objectsByName.set(obj.name, obj); + } + } +} + +const flagEntries = Object.entries(PUBLIC_AUTH_FEATURES) as Array< + [PublicAuthFeatureName, (typeof PUBLIC_AUTH_FEATURES)[PublicAuthFeatureName]] +>; + +/** Resolve `.actions.[.params.]` to its `visible`. */ +function resolveGatedInput(path: string): { visibleSource: string | undefined } { + const match = /^([a-z0-9_]+)\.actions\.([a-z0-9_]+)(?:\.params\.([A-Za-z0-9_]+))?$/.exec(path); + expect(match, `path grammar: ${path}`).not.toBeNull(); + const [, objectName, actionName, paramName] = match!; + const object = objectsByName.get(objectName); + expect(object, `object exists: ${path}`).toBeDefined(); + const action = (object!.actions ?? []).find((a) => a.name === actionName); + expect(action, `action exists: ${path}`).toBeDefined(); + if (!paramName) return { visibleSource: action!.visible?.source }; + const param = (action!.params ?? []).find((p) => (p.name ?? p.field) === paramName); + expect(param, `param exists: ${path}`).toBeDefined(); + return { visibleSource: param!.visible?.source }; +} + +describe('feature-gate completeness guard (#2874)', () => { + it('sanity: the walker actually sees the platform objects', () => { + for (const name of ['sys_user', 'sys_organization', 'sys_oauth_application', 'sys_two_factor']) { + expect(objectsByName.has(name), name).toBe(true); + } + }); + + describe('forward: every registered gated input carries the matching predicate', () => { + const rows = flagEntries.flatMap(([flag, entry]) => + (entry.gatedInputs ?? []).map((path): [string, PublicAuthFeatureName] => [path, flag]), + ); + + it.each(rows)('%s is gated on %s', (path, flag) => { + const gate = featureGatePredicate(flag); + const { visibleSource } = resolveGatedInput(path); + expect(visibleSource, `${path} has a visible predicate`).toBeDefined(); + const matches = visibleSource === gate || visibleSource!.endsWith(`&& ${gate}`); + expect(matches, `${path}: "${visibleSource}" must equal or end with "&& ${gate}"`).toBe(true); + }); + }); + + describe('reverse: every features.* gate in the objects is booked in the registry', () => { + const inputsByFlag = new Map>( + flagEntries.map(([flag, entry]) => [flag as string, new Set(entry.gatedInputs ?? [])]), + ); + + const referencedInputs: Array<[string, string]> = []; + for (const [objectName, object] of objectsByName) { + for (const action of object.actions ?? []) { + const sites: Array<[string, string | undefined]> = [ + [`${objectName}.actions.${action.name}`, action.visible?.source], + ...(action.params ?? []).map((p): [string, string | undefined] => [ + `${objectName}.actions.${action.name}.params.${p.name ?? p.field}`, + p.visible?.source, + ]), + ]; + for (const [path, source] of sites) { + for (const m of (source ?? '').matchAll(/features\.([A-Za-z0-9_]+)/g)) { + referencedInputs.push([path, m[1]]); + } + } + } + } + + it('finds the gated surface (guards the walker itself)', () => { + // 38 booked inputs exist today; if the walker ever goes blind and finds + // none, the it.each below would vacuously pass — pin a floor instead. + expect(referencedInputs.length).toBeGreaterThanOrEqual(38); + }); + + it.each(referencedInputs)('%s references registered flag %s and is booked', (path, flag) => { + const booked = inputsByFlag.get(flag); + expect(booked, `features.${flag} is a registry flag`).toBeDefined(); + expect(booked!.has(path), `${path} is booked in ${flag}.gatedInputs`).toBe(true); + }); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index ecfc1b4cfa..87bddbf5b7 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -50,7 +50,7 @@ export const SysInvitation = ObjectSchema.create({ // sys_organization.create_organization). The recipient-side // accept/reject actions below stay record-gated — they are // unreachable in single-org anyway (no invitation rows exist). - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Invitation sent', refreshAfter: true, params: [ @@ -68,7 +68,7 @@ export const SysInvitation = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/cancel-invitation', recordIdParam: 'invitationId', - visible: 'features.organization != false', + requiresFeature: 'organization', confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.', successMessage: 'Invitation canceled', refreshAfter: true, @@ -82,7 +82,7 @@ export const SysInvitation = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/invite-member', bodyExtra: { resend: true }, - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Invitation resent', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index e6c7f614a6..6d0a228ced 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -55,7 +55,7 @@ export const SysMember = ObjectSchema.create({ // better-auth endpoints resolve the session's active org, which // single-org mode now guarantees via plugin-auth's default-org // bootstrap. Same gate on every membership mutation below. - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Member added', refreshAfter: true, params: [ @@ -73,7 +73,7 @@ export const SysMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Member role updated', refreshAfter: true, params: [ @@ -90,7 +90,7 @@ export const SysMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-member', recordIdParam: 'memberIdOrEmail', - visible: 'features.organization != false', + requiresFeature: 'organization', confirmText: 'Remove this member from the organization? They will lose access to all org resources.', successMessage: 'Member removed', refreshAfter: true, @@ -112,7 +112,10 @@ export const SysMember = ObjectSchema.create({ target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', bodyExtra: { role: 'owner' }, - visible: "record.role != 'owner' && features.organization != false", + // The residual row predicate stays hand-written; the feature gate is + // AND-composed onto it by the requiresFeature lowering. + visible: "record.role != 'owner'", + requiresFeature: 'organization', confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.', successMessage: 'Ownership transferred', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 8793e2d9d7..69c464568c 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -65,6 +65,7 @@ export const SysOauthApplication = ObjectSchema.create({ type: 'api', method: 'POST', target: '/api/v1/auth/admin/oauth2/toggle-disabled', + requiresFeature: 'oidcProvider', confirmText: 'Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.', successMessage: 'OAuth application disabled', refreshAfter: true, @@ -84,6 +85,7 @@ export const SysOauthApplication = ObjectSchema.create({ type: 'api', method: 'POST', target: '/api/v1/auth/admin/oauth2/toggle-disabled', + requiresFeature: 'oidcProvider', confirmText: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.', successMessage: 'OAuth application enabled', refreshAfter: true, @@ -103,6 +105,7 @@ export const SysOauthApplication = ObjectSchema.create({ type: 'api', method: 'POST', target: '/api/v1/auth/sys-oauth-application/register', + requiresFeature: 'oidcProvider', refreshAfter: true, params: [ { name: 'name', label: 'Application Name', type: 'text', required: true }, @@ -134,6 +137,7 @@ export const SysOauthApplication = ObjectSchema.create({ type: 'api', method: 'POST', target: '/api/v1/auth/oauth2/client/rotate-secret', + requiresFeature: 'oidcProvider', confirmText: 'Rotate this OAuth client\'s secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once.', refreshAfter: true, params: [ @@ -158,6 +162,7 @@ export const SysOauthApplication = ObjectSchema.create({ type: 'api', method: 'POST', target: '/api/v1/auth/oauth2/delete-client', + requiresFeature: 'oidcProvider', confirmText: 'Permanently delete this OAuth application? All issued tokens and consents will be invalidated and integrations using this client_id will stop working immediately. This cannot be undone.', successMessage: 'OAuth application deleted', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index fcdf756767..658af59f19 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -49,7 +49,7 @@ export const SysOrganization = ObjectSchema.create({ // populated by the console/account shells from `/auth/config`; // we default to visible when the flag is undefined so we don't // accidentally hide the button while auth config is still loading. - visible: 'features.multiOrgEnabled != false', + requiresFeature: 'multiOrgEnabled', params: [ { field: 'name', required: true }, { field: 'slug', required: true }, @@ -70,7 +70,7 @@ export const SysOrganization = ObjectSchema.create({ // Org-admin actions are multi-org-only; hide them in single-org for // consistency with `create_organization` (the org list is empty there, // but this also guards direct record-URL access). - visible: 'features.multiOrgEnabled != false', + requiresFeature: 'multiOrgEnabled', successMessage: 'Organization updated', refreshAfter: true, params: [ @@ -89,7 +89,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/delete', recordIdParam: 'organizationId', - visible: 'features.multiOrgEnabled != false', + requiresFeature: 'multiOrgEnabled', confirmText: 'Delete this organization? All members will lose access immediately. This cannot be undone.', successMessage: 'Organization deleted', refreshAfter: true, @@ -108,7 +108,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/set-active', recordIdParam: 'organizationId', - visible: 'features.multiOrgEnabled != false', + requiresFeature: 'multiOrgEnabled', successMessage: 'Active organization switched', refreshAfter: true, }, @@ -125,7 +125,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/leave', recordIdParam: 'organizationId', - visible: 'features.multiOrgEnabled != false', + requiresFeature: 'multiOrgEnabled', confirmText: 'Leave this organization? You will lose access to all of its resources.', successMessage: 'You have left the organization', refreshAfter: true, @@ -147,7 +147,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/cloud/organizations/{id}/change-slug', method: 'POST', - visible: 'features.multiOrgEnabled != false', + requiresFeature: 'multiOrgEnabled', confirmText: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?', successMessage: 'Organization slug changed', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index a35a66f488..4b0ca33180 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -45,7 +45,7 @@ export const SysTeamMember = ObjectSchema.create({ // Team membership lives under organizations — multi-org-only. Gate // both mutations so they vanish in single-org (mirrors // sys_organization.create_organization). - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Team member added', refreshAfter: true, params: [ @@ -66,7 +66,7 @@ export const SysTeamMember = ObjectSchema.create({ locations: ['list_item'], type: 'api', target: '/api/v1/auth/organization/remove-team-member', - visible: 'features.organization != false', + requiresFeature: 'organization', confirmText: 'Remove this user from the team? They will lose any team-scoped access.', successMessage: 'Team member removed', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 564aad3fc4..4fa177c374 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -48,7 +48,7 @@ export const SysTeam = ObjectSchema.create({ // Teams are nested inside organizations — a multi-org-only concept. // Gate every team mutation on the multi-org flag so the affordances // disappear in single-org (mirrors sys_organization.create_organization). - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Team created', refreshAfter: true, params: [ @@ -69,7 +69,7 @@ export const SysTeam = ObjectSchema.create({ target: '/api/v1/auth/organization/update-team', recordIdParam: 'teamId', bodyShape: { wrap: 'data' }, - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Team updated', refreshAfter: true, params: [ @@ -88,7 +88,7 @@ export const SysTeam = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-team', recordIdParam: 'teamId', - visible: 'features.organization != false', + requiresFeature: 'organization', confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.', successMessage: 'Team deleted', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 66aee6c192..6c96438f7c 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -66,6 +66,7 @@ export const SysTwoFactor = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/two-factor/enable', + requiresFeature: 'twoFactor', refreshAfter: true, params: [ { name: 'password', label: 'Current Password', type: 'text', required: true }, @@ -88,6 +89,7 @@ export const SysTwoFactor = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/two-factor/disable', + requiresFeature: 'twoFactor', confirmText: 'Disable two-factor authentication on your account?', successMessage: '2FA disabled', refreshAfter: true, @@ -103,6 +105,7 @@ export const SysTwoFactor = ObjectSchema.create({ locations: ['list_toolbar', 'list_item'], type: 'api', target: '/api/v1/auth/two-factor/generate-backup-codes', + requiresFeature: 'twoFactor', confirmText: 'Regenerate backup codes? All previous backup codes will stop working immediately.', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 1cd6da47b2..692fa7840b 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -63,7 +63,7 @@ export const SysUser = ObjectSchema.create({ // "add a teammate" affordance — the Users list is always reachable — // and every add flows through better-auth invitations, never bespoke // sys_user CRUD. - visible: 'features.organization != false', + requiresFeature: 'organization', successMessage: 'Invitation sent', refreshAfter: true, params: [ @@ -75,9 +75,11 @@ export const SysUser = ObjectSchema.create({ // ── Platform admin operations (require better-auth `admin` plugin) ─ // // These actions hit /api/v1/auth/admin/* endpoints that are only - // wired when `auth.plugins.admin` is enabled. When the plugin is - // disabled the actions still render (schema is static) but server - // returns 404. UI surfaces them under the row menu AND the + // wired when `auth.plugins.admin` is enabled, so each one is gated on + // `requiresFeature: 'admin'` (#2874) — when the plugin is off the UI + // hides them instead of rendering buttons that 404. SCIM deployments + // keep them: SCIM forces the admin plugin (and `features.admin`) on + // (ADR-0071). UI surfaces them under the row menu AND the // record-detail header (`record_header`, overflowing into the ⋯ // "More" menu) so platform admins can manage an account from either // the Users list or an open user record — without dropping to SQL or @@ -90,6 +92,7 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/ban-user', + requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'User banned', refreshAfter: true, @@ -106,6 +109,7 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/unban-user', + requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'User unbanned', refreshAfter: true, @@ -121,6 +125,7 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/unlock-user', + requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Account unlocked', refreshAfter: true, @@ -140,7 +145,7 @@ export const SysUser = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/admin/create-user', - visible: 'features.admin == true', + requiresFeature: 'admin', successMessage: 'User created', refreshAfter: true, params: [ @@ -157,7 +162,7 @@ export const SysUser = ObjectSchema.create({ // otherwise the create-user endpoint rejects a phone with // "Phone numbers require the phoneNumber auth plugin". `features.phoneNumber` // is served in /api/v1/auth/config (getPublicConfig). - visible: 'features.phoneNumber == true', + requiresFeature: 'phoneNumber', }, { field: 'name', required: false }, { @@ -200,6 +205,7 @@ export const SysUser = ObjectSchema.create({ // legacy role scalar), can mint a temporary password, and stamps // must_change_password. target: '/api/v1/auth/admin/set-user-password', + requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Password updated', refreshAfter: false, @@ -238,6 +244,7 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/set-role', + requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Role updated', refreshAfter: true, @@ -253,6 +260,7 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/impersonate-user', + requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Now impersonating user', refreshAfter: true, @@ -371,6 +379,7 @@ export const SysUser = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/two-factor/enable', visible: 'record.id == ctx.user.id && record.two_factor_enabled != true', + requiresFeature: 'twoFactor', successMessage: 'Two-factor authentication enabled. Scan the QR code or paste the otpauth URI into your authenticator app, then verify a code to complete setup.', refreshAfter: true, params: [ @@ -386,6 +395,7 @@ export const SysUser = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/two-factor/disable', visible: 'record.id == ctx.user.id && record.two_factor_enabled == true', + requiresFeature: 'twoFactor', confirmText: 'Turn off two-factor authentication? Your account will be less secure.', successMessage: 'Two-factor authentication disabled.', refreshAfter: true, @@ -402,6 +412,7 @@ export const SysUser = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/two-factor/generate-backup-codes', visible: 'record.id == ctx.user.id && record.two_factor_enabled == true', + requiresFeature: 'twoFactor', confirmText: 'Generate a new set of backup codes? Any previously generated codes will stop working.', successMessage: 'New backup codes generated — save them somewhere safe.', refreshAfter: false, diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 763d3fdb82..a9642e96a7 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -160,10 +160,10 @@ describe('@objectstack/platform-objects', () => { // predicate, so exactly one is active at any time. expect(disable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled'); expect(disable?.bodyExtra).toEqual({ disabled: true }); - expect((disable?.visible as any)?.source).toBe('!record.disabled'); + expect((disable?.visible as any)?.source).toBe('(!record.disabled) && features.oidcProvider != false'); expect(enable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled'); expect(enable?.bodyExtra).toEqual({ disabled: false }); - expect((enable?.visible as any)?.source).toBe('record.disabled'); + expect((enable?.visible as any)?.source).toBe('(record.disabled) && features.oidcProvider != false'); // Generic CRUD must NOT expose mutating methods — all writes are // reserved for better-auth wrappers above so OAuth-specific @@ -278,3 +278,81 @@ describe('@objectstack/platform-objects', () => { }); }); }); + +// #2874 P2a — behavior-equivalence lock for the requiresFeature migration. +// Every hand-written `visible: 'features.*'` gate was replaced by the +// declarative `requiresFeature` sugar; these rows pin the LOWERED predicate +// to the exact CEL string that was previously hand-written, so the migration +// is provably behavior-neutral. (transfer_ownership composes a residual row +// predicate with the gate — parenthesized but operand/order-identical to the +// old `record.role != 'owner' && features.organization != false`.) +describe('feature-gate lowering matrix (#2874)', () => { + const ORG = 'features.organization != false'; + const MULTI_ORG = 'features.multiOrgEnabled != false'; + + const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [ + ['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG], + ['SysOrganization', SysOrganization, 'update_organization', MULTI_ORG], + ['SysOrganization', SysOrganization, 'delete_organization', MULTI_ORG], + ['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG], + ['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG], + ['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG], + ['SysUser', SysUser, 'invite_user', ORG], + ['SysUser', SysUser, 'create_user', 'features.admin == true'], + ['SysMember', SysMember, 'add_member', ORG], + ['SysMember', SysMember, 'update_member_role', ORG], + ['SysMember', SysMember, 'remove_member', ORG], + ['SysMember', SysMember, 'transfer_ownership', `(record.role != 'owner') && ${ORG}`], + ['SysInvitation', SysInvitation, 'invite_user', ORG], + ['SysInvitation', SysInvitation, 'cancel_invitation', ORG], + ['SysInvitation', SysInvitation, 'resend_invitation', ORG], + ['SysTeam', SysTeam, 'create_team', ORG], + ['SysTeam', SysTeam, 'update_team', ORG], + ['SysTeam', SysTeam, 'remove_team', ORG], + ['SysTeamMember', SysTeamMember, 'add_team_member', ORG], + ['SysTeamMember', SysTeamMember, 'remove_team_member', ORG], + // #2874 P2b — audit gates: capability-dependent actions that previously + // shipped UNGATED (rendered even with the backing plugin off, then 404'd). + ['SysUser', SysUser, 'ban_user', 'features.admin == true'], + ['SysUser', SysUser, 'unban_user', 'features.admin == true'], + ['SysUser', SysUser, 'unlock_user', 'features.admin == true'], + ['SysUser', SysUser, 'set_user_password', 'features.admin == true'], + ['SysUser', SysUser, 'set_user_role', 'features.admin == true'], + ['SysUser', SysUser, 'impersonate_user', 'features.admin == true'], + ['SysUser', SysUser, 'enable_two_factor', '(record.id == ctx.user.id && record.two_factor_enabled != true) && features.twoFactor == true'], + ['SysUser', SysUser, 'disable_two_factor', '(record.id == ctx.user.id && record.two_factor_enabled == true) && features.twoFactor == true'], + ['SysUser', SysUser, 'generate_backup_codes', '(record.id == ctx.user.id && record.two_factor_enabled == true) && features.twoFactor == true'], + ['SysTwoFactor', SysTwoFactor, 'enable_two_factor', 'features.twoFactor == true'], + ['SysTwoFactor', SysTwoFactor, 'disable_two_factor', 'features.twoFactor == true'], + ['SysTwoFactor', SysTwoFactor, 'regenerate_backup_codes', 'features.twoFactor == true'], + ['SysOauthApplication', SysOauthApplication, 'create_oauth_application', 'features.oidcProvider != false'], + ['SysOauthApplication', SysOauthApplication, 'delete_oauth_application', 'features.oidcProvider != false'], + ['SysOauthApplication', SysOauthApplication, 'disable_oauth_application', '(!record.disabled) && features.oidcProvider != false'], + ['SysOauthApplication', SysOauthApplication, 'enable_oauth_application', '(record.disabled) && features.oidcProvider != false'], + ['SysOauthApplication', SysOauthApplication, 'rotate_client_secret', 'features.oidcProvider != false'], + ]; + + it.each(rows)('%s.%s#%s lowers to the previous hand-written predicate', (_export, object, actionName, expected) => { + const action = (object.actions ?? []).find((a) => a.name === actionName); + expect(action, `${actionName} exists`).toBeDefined(); + expect((action?.visible as { source?: string })?.source).toBe(expected); + }); + + it('SysUser.create_user#phoneNumber param lowers to the previous hand-written predicate', () => { + const create = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); + const phone = (create?.params ?? []).find((p) => (p as { name?: string }).name === 'phoneNumber'); + expect(phone).toBeDefined(); + expect(((phone as { visible?: { source?: string } }).visible)?.source).toBe('features.phoneNumber == true'); + }); + + it('the requiresFeature sugar never survives into parsed objects', () => { + for (const [, object] of systemObjects) { + for (const action of object.actions ?? []) { + expect(action).not.toHaveProperty('requiresFeature'); + for (const param of (action as { params?: readonly unknown[] }).params ?? []) { + expect(param).not.toHaveProperty('requiresFeature'); + } + } + } + }); +}); diff --git a/packages/plugins/plugin-auth/src/public-feature-registry.test.ts b/packages/plugins/plugin-auth/src/public-feature-registry.test.ts new file mode 100644 index 0000000000..c8847e3d1f --- /dev/null +++ b/packages/plugins/plugin-auth/src/public-feature-registry.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Drift guard (#2874 P0): the public feature flags served by + * `getPublicConfig()` and the classification registry in + * `@objectstack/spec/kernel` (`PUBLIC_AUTH_FEATURES`) must stay in lockstep. + * + * Like `packages/mcp/src/skill-surface-guard.test.ts`, this derives the + * ACTUAL surface from the real code path (an AuthManager instance) instead of + * a hand-maintained list, and diffs it against the declared registry. Adding + * a flag to the `features` literal in auth-manager.ts without classifying it + * in the registry (gated inputs or an exemption reason) turns this red — the + * exact drift class that let issue #2874's own first draft miss 5 of 13 + * flags. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS, + PUBLIC_AUTH_FEATURE_NAMES, + PUBLIC_AUTH_FEATURES, +} from '@objectstack/spec/kernel'; +import { AuthManager } from './auth-manager.js'; + +function servedFeatures(config?: ConstructorParameters[0]): Record { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + ...config, + }); + return manager.getPublicConfig().features as Record; + } finally { + warnSpy.mockRestore(); + } +} + +describe('public feature-flag registry drift guard (#2874)', () => { + it('every boolean flag served by getPublicConfig() is classified in the registry, and vice versa', () => { + const features = servedFeatures(); + const booleanKeys = Object.keys(features).filter((k) => typeof features[k] === 'boolean'); + expect(booleanKeys.sort()).toEqual([...PUBLIC_AUTH_FEATURE_NAMES].sort()); + }); + + it('non-boolean keys are limited to the declared legal-link URLs', () => { + const features = servedFeatures(); + const nonBooleanKeys = Object.keys(features).filter((k) => typeof features[k] !== 'boolean'); + for (const key of nonBooleanKeys) { + expect(PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS).toContain(key); + } + }); + + // Some keys are computed conditionally (e.g. phoneNumberOtp = plugin && + // deliverability) — make sure no flag appears or disappears with config, so + // a conditionally-served key can't hide from the equivalence check above. + it('the flag key set is stable across plugin configurations', () => { + const defaults = servedFeatures(); + const variant = servedFeatures({ + plugins: { phoneNumber: true, admin: true, organization: false, twoFactor: true }, + } as never); + const booleans = (f: Record) => + Object.keys(f).filter((k) => typeof f[k] === 'boolean').sort(); + expect(booleans(variant)).toEqual(booleans(defaults)); + }); + + it('registry default semantics match the served defaults', () => { + // `default-on` flags must actually serve `true` by default and `opt-in` + // flags `false` — otherwise the lowered `!= false` / `== true` predicates + // would not reflect reality. `multiOrgEnabled` is default-on SEMANTICS + // (missing flag must fail open in the UI) even though a vanilla + // single-org deployment serves `false`, so it is exempt here. + const features = servedFeatures(); + for (const name of PUBLIC_AUTH_FEATURE_NAMES) { + if (name === 'multiOrgEnabled') continue; + const expected = PUBLIC_AUTH_FEATURES[name].semantics === 'default-on'; + expect(features[name], `features.${name} default`).toBe(expected); + } + }); +}); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index ba7c261406..b4efc7dd89 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1531,6 +1531,9 @@ "OpsPluginStructureSchema (const)", "PROTOCOL_MAJOR (const)", "PROTOCOL_VERSION (const)", + "PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)", + "PUBLIC_AUTH_FEATURES (const)", + "PUBLIC_AUTH_FEATURE_NAMES (const)", "PackageArtifact (type)", "PackageArtifactInput (type)", "PackageArtifactSchema (const)", @@ -1670,6 +1673,10 @@ "ProtocolReferenceSchema (const)", "ProtocolVersion (type)", "ProtocolVersionSchema (const)", + "PublicAuthFeatureEntry (type)", + "PublicAuthFeatureName (type)", + "PublicAuthFeatureSemantics (type)", + "PublicAuthFeatureSurface (type)", "RealTimeNotificationConfig (type)", "RealTimeNotificationConfigSchema (const)", "RequiredAction (type)", @@ -1759,12 +1766,14 @@ "evaluateLockForDelete (function)", "evaluateLockForWrite (function)", "extractProtection (function)", + "featureGatePredicate (function)", "getMetadataCreateSeed (function)", "getMetadataTypeActions (function)", "getMetadataTypeSchema (function)", "isConsumerInstallable (function)", "listMetadataCreateSeedTypes (function)", "listMetadataTypeSchemaTypes (function)", + "lowerRequiresFeature (function)", "registerMetadataTypeActions (function)", "registerMetadataTypeSchema (function)", "resolveLockState (function)", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 2de5885186..ba8dde9380 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -89,6 +89,11 @@ "status": "live", "note": "objectui — CEL, fail-closed." }, + "requiresFeature": { + "status": "live", + "evidence": "packages/spec/src/kernel/public-auth-features.ts (lowerRequiresFeature), wired as a .transform() in packages/spec/src/ui/action.zod.ts", + "note": "Author-facing sugar, consumed AT PARSE TIME: lowered into the (already-live) `visible` predicate per the PUBLIC_AUTH_FEATURES registry semantics and stripped from the output, so no downstream consumer ever sees it. Guarded by the #2874 drift + completeness tests (plugin-auth public-feature-registry.test.ts, platform-objects feature-gate-guard.test.ts)." + }, "requiredPermissions": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index a724e8a644..a14f2a6ae9 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -28,6 +28,7 @@ export * from './plugin-validator.zod'; export * from './plugin-versioning.zod'; export * from './protocol-version'; export * from './plugin.zod'; +export * from './public-auth-features'; export * from './service-registry.zod'; export * from './startup-orchestrator.zod'; export * from './plugin-registry.zod'; diff --git a/packages/spec/src/kernel/public-auth-features.test.ts b/packages/spec/src/kernel/public-auth-features.test.ts new file mode 100644 index 0000000000..d806fa1e80 --- /dev/null +++ b/packages/spec/src/kernel/public-auth-features.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + PUBLIC_AUTH_FEATURES, + PUBLIC_AUTH_FEATURE_NAMES, + featureGatePredicate, + lowerRequiresFeature, +} from './public-auth-features'; + +// Registry invariants (#2874 P0). The key-set ≡ getPublicConfig().features +// drift guard lives with the producer, in plugin-auth +// (public-feature-registry.test.ts) — this file checks the registry's own +// internal consistency. +describe('PUBLIC_AUTH_FEATURES registry', () => { + const entries = Object.entries(PUBLIC_AUTH_FEATURES); + + it('classifies all 13 public flags', () => { + expect(PUBLIC_AUTH_FEATURE_NAMES).toHaveLength(13); + expect([...PUBLIC_AUTH_FEATURE_NAMES].sort()).toEqual( + [ + 'admin', + 'degradedTenancy', + 'deviceAuthorization', + 'magicLink', + 'multiOrgEnabled', + 'oidcProvider', + 'organization', + 'passkeys', + 'phoneNumber', + 'phoneNumberOtp', + 'sso', + 'ssoEnforced', + 'twoFactor', + ].sort(), + ); + }); + + it.each(entries)('%s declares gatedInputs XOR an exemption reason', (_name, entry) => { + const hasGates = entry.gatedInputs !== undefined && entry.gatedInputs.length > 0; + const hasExempt = entry.exempt !== undefined && entry.exempt.reason.length > 0; + expect(hasGates !== hasExempt, 'exactly one of gatedInputs / exempt must be set').toBe(true); + }); + + it('gatedInputs paths follow the .actions.[.params.] grammar', () => { + const PATH = /^[a-z][a-z0-9_]*\.actions\.[a-z][a-z0-9_]*(\.params\.[A-Za-z][A-Za-z0-9_]*)?$/; + for (const [name, entry] of entries) { + for (const path of entry.gatedInputs ?? []) { + expect(path, `${name}: ${path}`).toMatch(PATH); + } + } + }); + + it('status-surface flags never gate inputs', () => { + for (const [, entry] of entries) { + if (entry.surface === 'status') expect(entry.gatedInputs).toBeUndefined(); + } + }); +}); + +describe('featureGatePredicate', () => { + it('opt-in flags gate with == true', () => { + expect(featureGatePredicate('phoneNumber')).toBe('features.phoneNumber == true'); + expect(featureGatePredicate('admin')).toBe('features.admin == true'); + }); + + it('default-on flags gate with != false (absent flag fails open)', () => { + expect(featureGatePredicate('organization')).toBe('features.organization != false'); + expect(featureGatePredicate('multiOrgEnabled')).toBe('features.multiOrgEnabled != false'); + }); +}); + +describe('lowerRequiresFeature', () => { + const noIssues = () => { + const issues: unknown[] = []; + return { + ctx: { addIssue: (i: unknown) => issues.push(i) } as never, + issues, + }; + }; + + it('passes through untouched when the sugar is absent', () => { + const { ctx } = noIssues(); + const input = { name: 'x', visible: { dialect: 'cel', source: 'record.a == 1' } }; + expect(lowerRequiresFeature(input, ctx)).toEqual(input); + }); + + it('emits the bare gate when no visible exists, and strips the sugar key', () => { + const { ctx } = noIssues(); + const out = lowerRequiresFeature({ name: 'x', requiresFeature: 'phoneNumber' as const }, ctx); + expect(out).toEqual({ name: 'x', visible: { dialect: 'cel', source: 'features.phoneNumber == true' } }); + expect('requiresFeature' in out).toBe(false); + }); + + it('composes with an existing CEL visible — existing first, gate last', () => { + const { ctx } = noIssues(); + const out = lowerRequiresFeature( + { + requiresFeature: 'organization' as const, + visible: { dialect: 'cel', source: "record.role != 'owner'" }, + }, + ctx, + ); + expect(out.visible).toEqual({ + dialect: 'cel', + source: "(record.role != 'owner') && features.organization != false", + }); + }); + + it('preserves envelope extras (meta) when composing', () => { + const { ctx } = noIssues(); + const out = lowerRequiresFeature( + { + requiresFeature: 'admin' as const, + visible: { dialect: 'cel', source: 'a', meta: { rationale: 'r' } }, + }, + ctx, + ); + expect(out.visible).toEqual({ + dialect: 'cel', + source: '(a) && features.admin == true', + meta: { rationale: 'r' }, + }); + }); + + it('rejects an AST-only or non-CEL visible loudly (ADR-0078)', () => { + for (const visible of [ + { dialect: 'cel', ast: { kind: 'literal' } }, + { dialect: 'js', source: 'true' }, + ]) { + const { ctx, issues } = noIssues(); + lowerRequiresFeature({ requiresFeature: 'admin' as const, visible }, ctx); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['requiresFeature'] }); + } + }); +}); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts new file mode 100644 index 0000000000..d3551f4cef --- /dev/null +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { z } from 'zod'; + +/** + * # Public auth feature-flag registry (#2874) + * + * Classification registry for the **public `/api/v1/auth/config` `features` + * contract** produced by plugin-auth's `getPublicConfig()`. Every boolean flag + * served there MUST be classified here — a drift guard in plugin-auth + * (`public-feature-registry.test.ts`) asserts the served key set ≡ this + * registry's key set, so a new flag that ships unclassified turns CI red. + * + * Why: the create-user `phoneNumber` bug (#2871 / objectui#2406) was one + * instance of a broader class — *the UI advertises a capability the runtime + * doesn't have because the plugin behind it is off*. Manual per-site gating + * discipline inevitably leaves silent gaps; this registry is the single place + * where each flag's consumption surface, default semantics, and gated spec + * inputs (or exemption rationale) are recorded and CI-enforced. + * + * NOT to be confused with `kernel/feature.zod.ts` (`FeatureFlagSchema`) — + * that schema models tenant-scoped runtime rollout toggles (strategies, + * expiry, conditions). This registry classifies the fixed, deployment-level + * capability flags that plugin-auth advertises to anonymous clients. + * + * Consumers: + * - `ui/action.zod.ts` — `requiresFeature` sugar on actions/params is lowered + * at parse time (see {@link lowerRequiresFeature}) into the canonical + * `visible` CEL predicate using {@link featureGatePredicate}. + * - plugin-auth drift guard (key-set equivalence with `getPublicConfig()`). + * - platform-objects completeness guard (`feature-gate-guard.test.ts`) — + * every path in `gatedInputs` must carry the matching predicate, and every + * `features.*` reference in a `visible` predicate must be booked here. + * + * This module is deliberately **import-free** (type-only zod import aside) so + * schema modules can depend on it file-directly without cycle risk, and it + * contains constants plus pure lowering helpers only (Prime Directive #2). + */ + +/** + * Where a flag is consumed: + * - `crud` — admin/CRUD surface: action/param `visible` predicates rendered + * through objectui's `filterVisibleParams` chain. The surface this registry + * actively guards. + * - `login` — objectui login/auth UI reads the flag straight off + * `/auth/config` (no spec metadata in between). + * - `status` — operational status indicator, not a capability gate. + */ +export type PublicAuthFeatureSurface = 'crud' | 'login' | 'status'; + +/** + * Default semantics of the flag, which decide the lowered predicate: + * - `opt-in` — default `false`; gate with `features.X == true`. + * - `default-on` — default `true`; gate with `features.X != false` so a + * missing/undefined flag (e.g. config not yet fetched) keeps the input + * visible. + */ +export type PublicAuthFeatureSemantics = 'opt-in' | 'default-on'; + +export type PublicAuthFeatureEntry = { + surface: PublicAuthFeatureSurface; + semantics: PublicAuthFeatureSemantics; + /** + * Spec inputs gated on this flag. Path grammar: + * `.actions.` or `.actions..params.`. + * The platform-objects completeness guard resolves each path and asserts the + * target's `visible` predicate matches {@link featureGatePredicate}. + * Mutually exclusive with `exempt`. + */ + gatedInputs?: readonly string[]; + /** Required when `gatedInputs` is absent — why no spec input needs gating. */ + exempt?: { reason: string }; + /** Audit notes: login-surface consumption sites, known gaps, follow-ups. */ + notes?: string; +}; + +/** + * The registry. Keys mirror the boolean flags assembled in plugin-auth's + * `getPublicConfig()` (auth-manager.ts, `features` literal) — see the drift + * guard. Login-surface consumption sites below were audited against objectui + * on 2026-07-15 (#2874 P2②). + */ +export const PUBLIC_AUTH_FEATURES = { + twoFactor: { + surface: 'crud', + semantics: 'opt-in', + gatedInputs: [ + 'sys_user.actions.enable_two_factor', + 'sys_user.actions.disable_two_factor', + 'sys_user.actions.generate_backup_codes', + 'sys_two_factor.actions.enable_two_factor', + 'sys_two_factor.actions.disable_two_factor', + 'sys_two_factor.actions.regenerate_backup_codes', + ], + notes: + 'Login-surface 2FA challenge is server-driven remediation (ADR-0069), ' + + 'so the flag is intentionally unread by objectui LoginForm.', + }, + passkeys: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'No spec input to gate. Typed in objectui (auth/src/types.ts) but no ' + + 'passkey UI exists yet — advertised-but-unconsumed gap tracked in ' + + 'objectui#2514 (#2874 P2②).', + }, + }, + magicLink: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'No spec input to gate. Typed in objectui (auth/src/types.ts) but no ' + + 'magic-link UI exists yet — advertised-but-unconsumed gap tracked in ' + + 'objectui#2514 (#2874 P2②).', + }, + }, + organization: { + surface: 'crud', + semantics: 'default-on', + gatedInputs: [ + 'sys_user.actions.invite_user', + 'sys_member.actions.add_member', + 'sys_member.actions.update_member_role', + 'sys_member.actions.remove_member', + 'sys_member.actions.transfer_ownership', + 'sys_invitation.actions.invite_user', + 'sys_invitation.actions.cancel_invitation', + 'sys_invitation.actions.resend_invitation', + 'sys_team.actions.create_team', + 'sys_team.actions.update_team', + 'sys_team.actions.remove_team', + 'sys_team_member.actions.add_team_member', + 'sys_team_member.actions.remove_team_member', + ], + notes: 'Org CAPABILITY gate, not multi-org (ADR-0081 D1).', + }, + multiOrgEnabled: { + surface: 'crud', + semantics: 'default-on', + gatedInputs: [ + 'sys_organization.actions.create_organization', + 'sys_organization.actions.update_organization', + 'sys_organization.actions.delete_organization', + 'sys_organization.actions.set_active_organization', + 'sys_organization.actions.leave_organization', + 'sys_organization.actions.change_slug', + ], + notes: + 'Reflects ACTUAL multi-tenancy capability (tenancy.mode === "multi", ' + + 'ADR-0093 D4), not just the requested mode.', + }, + degradedTenancy: { + surface: 'status', + semantics: 'opt-in', + exempt: { + reason: + 'Operator status banner (ADR-0093 D5) — signals degraded tenant ' + + 'isolation, not an input capability gate.', + }, + }, + oidcProvider: { + surface: 'crud', + semantics: 'default-on', + gatedInputs: [ + 'sys_oauth_application.actions.create_oauth_application', + 'sys_oauth_application.actions.delete_oauth_application', + 'sys_oauth_application.actions.disable_oauth_application', + 'sys_oauth_application.actions.enable_oauth_application', + 'sys_oauth_application.actions.rotate_client_secret', + ], + notes: + 'Default-ON: the embedded OIDC authorization server follows the ' + + 'default-on MCP surface (resolveOidcProviderEnabled). Login surface ' + + 'consumes the socialProviders[] array (per-provider enabled), not this ' + + 'flag.', + }, + sso: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'Deliberately ungated on the CRUD surface: the served value is ' + + 'refined to "usable" (≥1 provider configured) via isSsoUsable() at ' + + '/auth/config, so gating sys_sso_provider registration actions on it ' + + 'would deadlock first-provider setup. Login consumption verified: ' + + 'objectui LoginForm gates the "Sign in with SSO" button.', + }, + }, + ssoEnforced: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'Login-surface only: objectui LoginForm hides the password form and ' + + 'self-registration (break-glass link remains). No spec input to gate.', + }, + }, + deviceAuthorization: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'No spec input (sys_device_code declares no actions). Known gap: ' + + 'objectui DeviceAuthPage hits the device-auth endpoints without ' + + 'checking this flag (absent from its client type) — tracked in ' + + 'objectui#2513 (#2874 P2②).', + }, + }, + admin: { + surface: 'crud', + semantics: 'opt-in', + gatedInputs: [ + 'sys_user.actions.create_user', + 'sys_user.actions.ban_user', + 'sys_user.actions.unban_user', + 'sys_user.actions.unlock_user', + 'sys_user.actions.set_user_password', + 'sys_user.actions.set_user_role', + 'sys_user.actions.impersonate_user', + ], + notes: 'SCIM forces the admin plugin (and this flag) on — ADR-0071.', + }, + phoneNumber: { + surface: 'crud', + semantics: 'opt-in', + gatedInputs: ['sys_user.actions.create_user.params.phoneNumber'], + notes: + 'The original #2871 fix. Also read by objectui LoginForm for the ' + + 'phone+password sign-in mode.', + }, + phoneNumberOtp: { + surface: 'login', + semantics: 'opt-in', + exempt: { + reason: + 'Login-surface only: gates the "sign in with verification code" link ' + + '(LoginForm) and the phone branch of forgot-password. Only advertised ' + + 'when SMS is actually deliverable (#2780).', + }, + }, +} as const satisfies Record; + +export type PublicAuthFeatureName = keyof typeof PUBLIC_AUTH_FEATURES; + +/** Tuple of registry keys — feeds `z.enum(...)` for the `requiresFeature` sugar. */ +export const PUBLIC_AUTH_FEATURE_NAMES = Object.keys(PUBLIC_AUTH_FEATURES) as [ + PublicAuthFeatureName, + ...PublicAuthFeatureName[], +]; + +/** + * Non-boolean keys `getPublicConfig()` may conditionally spread into + * `features` (legal-link URLs). Exempt from flag classification; the drift + * guard asserts no OTHER non-boolean key sneaks in. + */ +export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl'] as const; + +/** + * The canonical CEL gate for a flag, per its default semantics: + * `opt-in` → `features.X == true`; `default-on` → `features.X != false` + * (so an absent flag — e.g. config not yet fetched — fails open). + */ +export function featureGatePredicate(name: PublicAuthFeatureName): string { + const op = PUBLIC_AUTH_FEATURES[name].semantics === 'opt-in' ? '== true' : '!= false'; + return `features.${name} ${op}`; +} + +/** Object shape the lowering transform operates on (post field-level parse). */ +type WithRequiresFeature = { + requiresFeature?: PublicAuthFeatureName; + /** Already normalized by ExpressionInputSchema to the `{dialect, source}` envelope. */ + visible?: { dialect?: unknown; source?: unknown } & Record; +}; + +/** + * Lower the declarative `requiresFeature: ''` sugar into the canonical + * `visible` CEL predicate and strip the sugar key from the output — mirroring + * `normalizeVisibleWhen` (ADR-0089): persisted artifacts, lint, runtime, and + * objectui only ever see the canonical envelope. + * + * - No existing `visible` → `{ dialect: 'cel', source: }`, string-equal + * to the hand-written gates it replaces. + * - Existing CEL `visible` with a `source` → composed as + * `() && ` (existing predicate first, gate last — the + * hand-written convention). + * - Existing `visible` that is non-CEL or AST-only → loud parse error + * (ADR-0078 no-silently-inert); write the combined predicate by hand. + * + * Designed as a zod `.transform((v, ctx) => lowerRequiresFeature(v, ctx))` + * appended after the schema's refinements. + */ +export function lowerRequiresFeature( + input: T, + ctx: z.core.$RefinementCtx, +): Omit { + const { requiresFeature, ...rest } = input; + if (requiresFeature === undefined) return rest as Omit; + + const gate = featureGatePredicate(requiresFeature); + const existing = rest.visible; + if (existing === undefined) { + return { ...rest, visible: { dialect: 'cel', source: gate } } as Omit; + } + if (existing.dialect !== 'cel' || typeof existing.source !== 'string') { + ctx.addIssue({ + code: 'custom', + path: ['requiresFeature'], + message: + '`requiresFeature` composes only with a CEL `visible` carrying a `source` string; ' + + 'this expression is AST-only or non-CEL — write the combined predicate by hand.', + }); + return rest as Omit; + } + return { + ...rest, + visible: { ...existing, source: `(${existing.source}) && ${gate}` }, + } as Omit; +} diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 9bf4b9b651..9a911102b3 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -40,6 +40,81 @@ describe('ActionParamSchema', () => { }); }); +// #2874 P1 — declarative `requiresFeature` sugar, lowered at parse time into +// the canonical `visible` CEL predicate (semantics per the +// PUBLIC_AUTH_FEATURES registry) and stripped from the output. +describe('requiresFeature lowering', () => { + it('lowers an opt-in flag to == true on a param', () => { + const result = ActionParamSchema.parse({ name: 'phoneNumber', requiresFeature: 'phoneNumber' }); + expect(result.visible).toEqual({ dialect: 'cel', source: 'features.phoneNumber == true' }); + expect(result).not.toHaveProperty('requiresFeature'); + }); + + it('lowers a default-on flag to != false on an action', () => { + const result = ActionSchema.parse({ + name: 'invite_user', + label: 'Invite', + type: 'api', + target: '/api/v1/auth/organization/invite-member', + requiresFeature: 'organization', + }); + expect(result.visible).toEqual({ dialect: 'cel', source: 'features.organization != false' }); + expect(result).not.toHaveProperty('requiresFeature'); + }); + + it('AND-composes with an explicit string visible — existing first, gate last', () => { + const result = ActionSchema.parse({ + name: 'transfer_ownership', + label: 'Transfer Ownership', + type: 'api', + target: '/api/v1/auth/organization/update-member-role', + visible: "record.role != 'owner'", + requiresFeature: 'organization', + }); + expect(result.visible).toEqual({ + dialect: 'cel', + source: "(record.role != 'owner') && features.organization != false", + }); + }); + + it('AND-composes with an explicit envelope visible, preserving meta', () => { + const result = ActionParamSchema.parse({ + name: 'x', + visible: { dialect: 'cel', source: 'record.a == 1', meta: { rationale: 'why' } }, + requiresFeature: 'admin', + }); + expect(result.visible).toEqual({ + dialect: 'cel', + source: '(record.a == 1) && features.admin == true', + meta: { rationale: 'why' }, + }); + }); + + it('rejects an unknown flag name at parse time', () => { + expect(() => ActionParamSchema.parse({ name: 'x', requiresFeature: 'phoneNumbr' })).toThrow(); + }); + + it('rejects composition with an AST-only visible loudly (ADR-0078)', () => { + const result = ActionParamSchema.safeParse({ + name: 'x', + visible: { dialect: 'cel', ast: { kind: 'literal' } }, + requiresFeature: 'admin', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes('requiresFeature'))).toBe(true); + } + }); + + it('leaves sugar-free inputs untouched', () => { + const result = ActionParamSchema.parse({ name: 'x', visible: 'features.phoneNumber == true' }); + expect(result.visible).toEqual({ dialect: 'cel', source: 'features.phoneNumber == true' }); + const bare = ActionParamSchema.parse({ name: 'y' }); + expect(bare.visible).toBeUndefined(); + expect(bare).not.toHaveProperty('requiresFeature'); + }); +}); + describe('ActionSchema', () => { describe('Basic Action Properties', () => { it('should accept minimal action', () => { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 611750264a..c98025e6ef 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -6,6 +6,9 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { HookBodySchema } from '../data/hook-body.zod'; +// Imported file-directly (not via the kernel barrel): the module is +// deliberately import-free, so this cannot introduce a cycle. +import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/public-auth-features'; /** * Action Parameter Schema @@ -75,10 +78,21 @@ export const ActionParamSchema = lazySchema(() => z.object({ * default backend rejects. Absent = always visible. */ visible: ExpressionInputSchema.optional().describe('Param visibility predicate (CEL); omits the param when false.'), + /** + * Declarative capability gate (#2874): name a public auth feature flag + * (see `PUBLIC_AUTH_FEATURES` in `@objectstack/spec/kernel`) and the schema + * lowers it at parse time into the canonical `visible` predicate — + * `features.X == true` (opt-in flag) or `features.X != false` (default-on), + * AND-composed with any explicit `visible`. The sugar key is stripped from + * the parsed output, so renderers/lint only ever see `visible`. Prefer this + * over a hand-written `features.*` predicate: the flag name is + * enum-checked and the gate/registry stay in lockstep. + */ + requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe('Public auth feature flag gating this param; lowered into `visible` at parse time.'), }).refine( (p) => Boolean(p.name) || Boolean(p.field), { message: 'ActionParam requires either "name" or "field"' }, -)); +).transform((p, ctx) => lowerRequiresFeature(p, ctx))); /** * Action type enum values. @@ -419,6 +433,14 @@ export const ActionSchema = lazySchema(() => z.object({ /** Access */ visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL).'), + /** + * Declarative capability gate (#2874) — action-level twin of the param + * `requiresFeature`. Lowered at parse time into `visible` (`== true` for + * opt-in flags, `!= false` for default-on; AND-composed with an explicit + * `visible`) and stripped from the output. See `PUBLIC_AUTH_FEATURES` in + * `@objectstack/spec/kernel`. + */ + requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe('Public auth feature flag gating this action; lowered into `visible` at parse time.'), disabled: z.union([z.boolean(), ExpressionInputSchema]).optional().describe('Boolean or predicate (CEL) — action is disabled when TRUE.'), /** @@ -563,7 +585,7 @@ export const ActionSchema = lazySchema(() => z.object({ }, { message: 'ai.paramHints keys must match a declared param name (or "recordId").', path: ['ai', 'paramHints'], -})); +}).transform((data, ctx) => lowerRequiresFeature(data, ctx))); export type Action = z.infer; export type ActionParam = z.infer;