Skip to content
Merged
10 changes: 10 additions & 0 deletions .changeset/feature-gate-registry-and-requires-feature.md
Original file line number Diff line number Diff line change
@@ -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: '<flag>'` (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.
24 changes: 23 additions & 1 deletion content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -291,6 +292,27 @@ Prefer the `record.<field>` 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: '<flag>'`** 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`.
Expand Down Expand Up @@ -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)

Expand Down
25 changes: 2 additions & 23 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions packages/platform-objects/src/feature-gate-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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.<name>` 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<string, AnyObject>();
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 `<object>.actions.<action>[.params.<name|field>]` 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<string, Set<string>>(
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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,
Expand All @@ -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: [
Expand Down
11 changes: 7 additions & 4 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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: [
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 },
Expand Down Expand Up @@ -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: [
Expand All @@ -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,
Expand Down
Loading