Skip to content

Commit e7d5291

Browse files
os-zhuangclaude
andauthored
feat(spec,auth): guard that every feature-gated capability is UI-gated (#2874) (#2965)
* feat(spec,auth): add public auth feature-flag classification registry + drift guard (#2874 P0) Single registry (PUBLIC_AUTH_FEATURES) classifying all 13 boolean flags served by getPublicConfig() at /auth/config: consumption surface (crud/login/status), default semantics (opt-in '== true' vs default-on '!= false'), and gated spec inputs or an exemption reason. Includes featureGatePredicate + lowerRequiresFeature helpers for the upcoming requiresFeature sugar (P1). The plugin-auth drift guard derives the ACTUAL served key set from a real AuthManager and asserts it equals the registry keys, so a new flag that ships unclassified turns CI red. A semantics check also asserts each flag's registered default matches what getPublicConfig() actually serves - it already caught oidcProvider being default-ON (follows the MCP surface), which issue #2874's own table misclassified as opt-in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * feat(spec): declarative requiresFeature sugar on actions/params, lowered to visible CEL (#2874 P1) ActionSchema and ActionParamSchema gain an optional requiresFeature: <flag> (z.enum over the registry keys, so a typo fails the parse). A .transform() appended after the schemas' refinements lowers it at parse time into the canonical visible predicate - features.X == true for opt-in flags, features.X != false for default-on (per PUBLIC_AUTH_FEATURES semantics) - AND-composing with any explicit visible ((existing) && gate) and stripping the sugar key from the output, mirroring the normalizeVisibleWhen pattern (ADR-0089). A non-CEL or AST-only visible rejects loudly (ADR-0078) instead of silently dropping the gate. Renderers, lint, and objectui only ever see the canonical visible envelope - the objectui rendering chain needs zero changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * refactor(platform-objects): migrate all 22 hand-written features.* gates to requiresFeature (#2874 P2a) Mechanical migration of every visible: 'features.X ...' predicate in the identity objects to the declarative requiresFeature annotation, including the compound transfer_ownership gate (residual row predicate stays hand-written; the lowering AND-composes the feature gate onto it). A behavior-equivalence matrix in platform-objects.test.ts pins each lowered visible.source to the exact CEL string that was previously hand-written (parenthesized-composition for the compound row), and asserts the sugar key never survives into parsed objects - so the migration is provably behavior-neutral. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * feat(spec,platform-objects): gate the audited admin/twoFactor/oidcProvider surfaces + bidirectional completeness guard (#2874 P2b) Audit fixes - capability-dependent actions that previously rendered with the backing plugin off and then 404'd: - requiresFeature: 'admin' on the six sys_user platform-admin actions (ban/unban/unlock/set_user_password/set_user_role/impersonate_user); the stale 'still render but server returns 404' comment is updated. SCIM deployments keep them (SCIM forces features.admin on, ADR-0071). - requiresFeature: 'twoFactor' on the three sys_two_factor actions and the three sys_user self-service 2FA actions (composed onto their existing record.id == ctx.user.id predicates). - requiresFeature: 'oidcProvider' on the five sys_oauth_application actions (composed onto the enable/disable record predicates). feature-gate-guard.test.ts asserts registry<->objects lockstep in both directions: every gatedInputs path resolves to a real input carrying the flag's lowered predicate (removing an annotation goes red), and every features.* reference in a visible predicate is booked in the registry (adding a gate without bookkeeping goes red). Login-surface audit gaps are recorded in the registry and tracked in objectui#2513 (DeviceAuthPage unguarded) and objectui#2514 (passkeys/magicLink advertised but unconsumed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * chore: changeset for the feature-gate registry + requiresFeature work (#2874) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * docs(protocol): document requiresFeature capability gates on actions/params (#2874) Also lists the param-level visible predicate (added in #2871) in the ActionParam field summary, which had drifted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * fix(ci): classify action/requiresFeature in the liveness ledger; avoid reserved word in docs example - Spec property liveness ratchet: requiresFeature is live at parse time (lowered into the already-live visible predicate and stripped), with evidence pointing at lowerRequiresFeature and the #2874 guard tests. - check-role-word (ADR-0090 D3): swap the docs composition example from transfer_ownership to disable_oauth_application so no new use of the reserved word ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * fix(ci): commit api-surface snapshot (9 intentional additions) + regenerated action reference doc - api-surface: the #2874 registry exports (PUBLIC_AUTH_FEATURES et al.) are intentional public API - snapshot updated via gen:api-surface. - references/ui/action.mdx: regenerated. ActionParamSchema now ends in a .transform() (the requiresFeature lowering), which z.toJSONSchema cannot represent on the output side, so the generator drops the ActionParam block - the same pre-existing behavior as ActionSchema and ObjectSchema. The authoritative ActionParam field documentation lives in the hand-written protocol doc (content/docs/protocol/objectui/actions.mdx), updated in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 274b894 commit e7d5291

20 files changed

Lines changed: 937 additions & 29 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
**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).**
7+
8+
`@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.
9+
10+
`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.

content/docs/protocol/objectui/actions.mdx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ interface Action {
189189
190190
// Access
191191
visible?: ExpressionInput; // Visibility predicate (CEL)
192+
requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time
192193
disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL)
193194

194195
// Bulk
@@ -291,6 +292,27 @@ Prefer the `record.<field>` form: it reads unambiguously and resolves identicall
291292

292293
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.
293294

295+
#### Capability gates: `requiresFeature`
296+
297+
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.
298+
299+
```yaml
300+
# Hidden entirely when the admin plugin is off (instead of a button that 404s)
301+
name: ban_user
302+
label: Ban User
303+
type: api
304+
target: /api/v1/auth/admin/ban-user
305+
requiresFeature: admin
306+
307+
# Composes with a residual row predicate:
308+
# (!record.disabled) && features.oidcProvider != false
309+
name: disable_oauth_application
310+
visible: "!record.disabled"
311+
requiresFeature: oidcProvider
312+
```
313+
314+
This is how the platform keeps "form follows plugin" (#2874): the UI never advertises an input the default backend would reject.
315+
294316
## Confirmation & Feedback
295317

296318
Actions express confirmation and feedback through dedicated string fields — there is no nested confirm-dialog object with `requireTyping`/`confirmLabel`.
@@ -375,7 +397,7 @@ params:
375397
- { label: High, value: high }
376398
```
377399

378-
`ActionParam` fields: `name`, `field`, `objectOverride`, `label`, `type`, `required` (default `false`), `options`, `placeholder`, `helpText`, `defaultValue`, `defaultFromRow`.
400+
`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)).
379401

380402
## AI Tool Exposure (ADR-0011)
381403

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Completeness guard (#2874 P2): the PUBLIC_AUTH_FEATURES registry
5+
* (`@objectstack/spec/kernel`) and the feature gates actually carried by the
6+
* platform objects must stay in lockstep, in BOTH directions:
7+
*
8+
* 1. Forward (registry → objects): every `gatedInputs` path must resolve to a
9+
* real action/param whose lowered `visible` predicate carries the flag's
10+
* gate. Removing a `requiresFeature` annotation (or the whole input)
11+
* without updating the registry turns this red.
12+
* 2. Reverse (objects → registry): every `features.<name>` reference inside
13+
* any action/param `visible` predicate must name a registry flag AND be
14+
* booked in that flag's `gatedInputs`. Adding a gate without registry
15+
* bookkeeping turns this red.
16+
*
17+
* Scope notes: objects that moved to capability plugins per ADR-0029 K2
18+
* (plugin-security / plugin-sharing / plugin-audit / service-realtime /
19+
* plugin-webhooks) are outside this package and this guard; none of them
20+
* reference `features.*` today. Object FIELDS (`visibleWhen`) carry no
21+
* feature gates today and are not walked. Per the issue's explicit non-goal,
22+
* this guard cannot catch a brand-new capability-dependent input that was
23+
* never annotated at all — that ground truth lives in plugin runtime
24+
* behavior.
25+
*/
26+
27+
import { describe, expect, it } from 'vitest';
28+
import {
29+
PUBLIC_AUTH_FEATURES,
30+
featureGatePredicate,
31+
type PublicAuthFeatureName,
32+
} from '@objectstack/spec/kernel';
33+
import * as identity from './identity/index.js';
34+
import * as metadata from './metadata/index.js';
35+
import * as system from './system/index.js';
36+
37+
type AnyParam = { name?: string; field?: string; visible?: { source?: string } };
38+
type AnyAction = { name?: string; visible?: { source?: string }; params?: AnyParam[] };
39+
type AnyObject = { name?: string; actions?: AnyAction[] };
40+
41+
// Every exported platform object, keyed by machine name (object.name).
42+
const objectsByName = new Map<string, AnyObject>();
43+
for (const mod of [identity, metadata, system]) {
44+
for (const value of Object.values(mod)) {
45+
const obj = value as AnyObject;
46+
if (obj && typeof obj === 'object' && typeof obj.name === 'string' && obj.name.startsWith('sys_')) {
47+
objectsByName.set(obj.name, obj);
48+
}
49+
}
50+
}
51+
52+
const flagEntries = Object.entries(PUBLIC_AUTH_FEATURES) as Array<
53+
[PublicAuthFeatureName, (typeof PUBLIC_AUTH_FEATURES)[PublicAuthFeatureName]]
54+
>;
55+
56+
/** Resolve `<object>.actions.<action>[.params.<name|field>]` to its `visible`. */
57+
function resolveGatedInput(path: string): { visibleSource: string | undefined } {
58+
const match = /^([a-z0-9_]+)\.actions\.([a-z0-9_]+)(?:\.params\.([A-Za-z0-9_]+))?$/.exec(path);
59+
expect(match, `path grammar: ${path}`).not.toBeNull();
60+
const [, objectName, actionName, paramName] = match!;
61+
const object = objectsByName.get(objectName);
62+
expect(object, `object exists: ${path}`).toBeDefined();
63+
const action = (object!.actions ?? []).find((a) => a.name === actionName);
64+
expect(action, `action exists: ${path}`).toBeDefined();
65+
if (!paramName) return { visibleSource: action!.visible?.source };
66+
const param = (action!.params ?? []).find((p) => (p.name ?? p.field) === paramName);
67+
expect(param, `param exists: ${path}`).toBeDefined();
68+
return { visibleSource: param!.visible?.source };
69+
}
70+
71+
describe('feature-gate completeness guard (#2874)', () => {
72+
it('sanity: the walker actually sees the platform objects', () => {
73+
for (const name of ['sys_user', 'sys_organization', 'sys_oauth_application', 'sys_two_factor']) {
74+
expect(objectsByName.has(name), name).toBe(true);
75+
}
76+
});
77+
78+
describe('forward: every registered gated input carries the matching predicate', () => {
79+
const rows = flagEntries.flatMap(([flag, entry]) =>
80+
(entry.gatedInputs ?? []).map((path): [string, PublicAuthFeatureName] => [path, flag]),
81+
);
82+
83+
it.each(rows)('%s is gated on %s', (path, flag) => {
84+
const gate = featureGatePredicate(flag);
85+
const { visibleSource } = resolveGatedInput(path);
86+
expect(visibleSource, `${path} has a visible predicate`).toBeDefined();
87+
const matches = visibleSource === gate || visibleSource!.endsWith(`&& ${gate}`);
88+
expect(matches, `${path}: "${visibleSource}" must equal or end with "&& ${gate}"`).toBe(true);
89+
});
90+
});
91+
92+
describe('reverse: every features.* gate in the objects is booked in the registry', () => {
93+
const inputsByFlag = new Map<string, Set<string>>(
94+
flagEntries.map(([flag, entry]) => [flag as string, new Set(entry.gatedInputs ?? [])]),
95+
);
96+
97+
const referencedInputs: Array<[string, string]> = [];
98+
for (const [objectName, object] of objectsByName) {
99+
for (const action of object.actions ?? []) {
100+
const sites: Array<[string, string | undefined]> = [
101+
[`${objectName}.actions.${action.name}`, action.visible?.source],
102+
...(action.params ?? []).map((p): [string, string | undefined] => [
103+
`${objectName}.actions.${action.name}.params.${p.name ?? p.field}`,
104+
p.visible?.source,
105+
]),
106+
];
107+
for (const [path, source] of sites) {
108+
for (const m of (source ?? '').matchAll(/features\.([A-Za-z0-9_]+)/g)) {
109+
referencedInputs.push([path, m[1]]);
110+
}
111+
}
112+
}
113+
}
114+
115+
it('finds the gated surface (guards the walker itself)', () => {
116+
// 38 booked inputs exist today; if the walker ever goes blind and finds
117+
// none, the it.each below would vacuously pass — pin a floor instead.
118+
expect(referencedInputs.length).toBeGreaterThanOrEqual(38);
119+
});
120+
121+
it.each(referencedInputs)('%s references registered flag %s and is booked', (path, flag) => {
122+
const booked = inputsByFlag.get(flag);
123+
expect(booked, `features.${flag} is a registry flag`).toBeDefined();
124+
expect(booked!.has(path), `${path} is booked in ${flag}.gatedInputs`).toBe(true);
125+
});
126+
});
127+
});

packages/platform-objects/src/identity/sys-invitation.object.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const SysInvitation = ObjectSchema.create({
5050
// sys_organization.create_organization). The recipient-side
5151
// accept/reject actions below stay record-gated — they are
5252
// unreachable in single-org anyway (no invitation rows exist).
53-
visible: 'features.organization != false',
53+
requiresFeature: 'organization',
5454
successMessage: 'Invitation sent',
5555
refreshAfter: true,
5656
params: [
@@ -68,7 +68,7 @@ export const SysInvitation = ObjectSchema.create({
6868
type: 'api',
6969
target: '/api/v1/auth/organization/cancel-invitation',
7070
recordIdParam: 'invitationId',
71-
visible: 'features.organization != false',
71+
requiresFeature: 'organization',
7272
confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.',
7373
successMessage: 'Invitation canceled',
7474
refreshAfter: true,
@@ -82,7 +82,7 @@ export const SysInvitation = ObjectSchema.create({
8282
type: 'api',
8383
target: '/api/v1/auth/organization/invite-member',
8484
bodyExtra: { resend: true },
85-
visible: 'features.organization != false',
85+
requiresFeature: 'organization',
8686
successMessage: 'Invitation resent',
8787
refreshAfter: true,
8888
params: [

packages/platform-objects/src/identity/sys-member.object.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const SysMember = ObjectSchema.create({
5555
// better-auth endpoints resolve the session's active org, which
5656
// single-org mode now guarantees via plugin-auth's default-org
5757
// bootstrap. Same gate on every membership mutation below.
58-
visible: 'features.organization != false',
58+
requiresFeature: 'organization',
5959
successMessage: 'Member added',
6060
refreshAfter: true,
6161
params: [
@@ -73,7 +73,7 @@ export const SysMember = ObjectSchema.create({
7373
type: 'api',
7474
target: '/api/v1/auth/organization/update-member-role',
7575
recordIdParam: 'memberId',
76-
visible: 'features.organization != false',
76+
requiresFeature: 'organization',
7777
successMessage: 'Member role updated',
7878
refreshAfter: true,
7979
params: [
@@ -90,7 +90,7 @@ export const SysMember = ObjectSchema.create({
9090
type: 'api',
9191
target: '/api/v1/auth/organization/remove-member',
9292
recordIdParam: 'memberIdOrEmail',
93-
visible: 'features.organization != false',
93+
requiresFeature: 'organization',
9494
confirmText: 'Remove this member from the organization? They will lose access to all org resources.',
9595
successMessage: 'Member removed',
9696
refreshAfter: true,
@@ -112,7 +112,10 @@ export const SysMember = ObjectSchema.create({
112112
target: '/api/v1/auth/organization/update-member-role',
113113
recordIdParam: 'memberId',
114114
bodyExtra: { role: 'owner' },
115-
visible: "record.role != 'owner' && features.organization != false",
115+
// The residual row predicate stays hand-written; the feature gate is
116+
// AND-composed onto it by the requiresFeature lowering.
117+
visible: "record.role != 'owner'",
118+
requiresFeature: 'organization',
116119
confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.',
117120
successMessage: 'Ownership transferred',
118121
refreshAfter: true,

packages/platform-objects/src/identity/sys-oauth-application.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const SysOauthApplication = ObjectSchema.create({
6565
type: 'api',
6666
method: 'POST',
6767
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
68+
requiresFeature: 'oidcProvider',
6869
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.',
6970
successMessage: 'OAuth application disabled',
7071
refreshAfter: true,
@@ -84,6 +85,7 @@ export const SysOauthApplication = ObjectSchema.create({
8485
type: 'api',
8586
method: 'POST',
8687
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
88+
requiresFeature: 'oidcProvider',
8789
confirmText: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.',
8890
successMessage: 'OAuth application enabled',
8991
refreshAfter: true,
@@ -103,6 +105,7 @@ export const SysOauthApplication = ObjectSchema.create({
103105
type: 'api',
104106
method: 'POST',
105107
target: '/api/v1/auth/sys-oauth-application/register',
108+
requiresFeature: 'oidcProvider',
106109
refreshAfter: true,
107110
params: [
108111
{ name: 'name', label: 'Application Name', type: 'text', required: true },
@@ -134,6 +137,7 @@ export const SysOauthApplication = ObjectSchema.create({
134137
type: 'api',
135138
method: 'POST',
136139
target: '/api/v1/auth/oauth2/client/rotate-secret',
140+
requiresFeature: 'oidcProvider',
137141
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.',
138142
refreshAfter: true,
139143
params: [
@@ -158,6 +162,7 @@ export const SysOauthApplication = ObjectSchema.create({
158162
type: 'api',
159163
method: 'POST',
160164
target: '/api/v1/auth/oauth2/delete-client',
165+
requiresFeature: 'oidcProvider',
161166
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.',
162167
successMessage: 'OAuth application deleted',
163168
refreshAfter: true,

packages/platform-objects/src/identity/sys-organization.object.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const SysOrganization = ObjectSchema.create({
4949
// populated by the console/account shells from `/auth/config`;
5050
// we default to visible when the flag is undefined so we don't
5151
// accidentally hide the button while auth config is still loading.
52-
visible: 'features.multiOrgEnabled != false',
52+
requiresFeature: 'multiOrgEnabled',
5353
params: [
5454
{ field: 'name', required: true },
5555
{ field: 'slug', required: true },
@@ -70,7 +70,7 @@ export const SysOrganization = ObjectSchema.create({
7070
// Org-admin actions are multi-org-only; hide them in single-org for
7171
// consistency with `create_organization` (the org list is empty there,
7272
// but this also guards direct record-URL access).
73-
visible: 'features.multiOrgEnabled != false',
73+
requiresFeature: 'multiOrgEnabled',
7474
successMessage: 'Organization updated',
7575
refreshAfter: true,
7676
params: [
@@ -89,7 +89,7 @@ export const SysOrganization = ObjectSchema.create({
8989
type: 'api',
9090
target: '/api/v1/auth/organization/delete',
9191
recordIdParam: 'organizationId',
92-
visible: 'features.multiOrgEnabled != false',
92+
requiresFeature: 'multiOrgEnabled',
9393
confirmText: 'Delete this organization? All members will lose access immediately. This cannot be undone.',
9494
successMessage: 'Organization deleted',
9595
refreshAfter: true,
@@ -108,7 +108,7 @@ export const SysOrganization = ObjectSchema.create({
108108
type: 'api',
109109
target: '/api/v1/auth/organization/set-active',
110110
recordIdParam: 'organizationId',
111-
visible: 'features.multiOrgEnabled != false',
111+
requiresFeature: 'multiOrgEnabled',
112112
successMessage: 'Active organization switched',
113113
refreshAfter: true,
114114
},
@@ -125,7 +125,7 @@ export const SysOrganization = ObjectSchema.create({
125125
type: 'api',
126126
target: '/api/v1/auth/organization/leave',
127127
recordIdParam: 'organizationId',
128-
visible: 'features.multiOrgEnabled != false',
128+
requiresFeature: 'multiOrgEnabled',
129129
confirmText: 'Leave this organization? You will lose access to all of its resources.',
130130
successMessage: 'You have left the organization',
131131
refreshAfter: true,
@@ -147,7 +147,7 @@ export const SysOrganization = ObjectSchema.create({
147147
type: 'api',
148148
target: '/api/v1/cloud/organizations/{id}/change-slug',
149149
method: 'POST',
150-
visible: 'features.multiOrgEnabled != false',
150+
requiresFeature: 'multiOrgEnabled',
151151
confirmText: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?',
152152
successMessage: 'Organization slug changed',
153153
refreshAfter: true,

packages/platform-objects/src/identity/sys-team-member.object.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const SysTeamMember = ObjectSchema.create({
4545
// Team membership lives under organizations — multi-org-only. Gate
4646
// both mutations so they vanish in single-org (mirrors
4747
// sys_organization.create_organization).
48-
visible: 'features.organization != false',
48+
requiresFeature: 'organization',
4949
successMessage: 'Team member added',
5050
refreshAfter: true,
5151
params: [
@@ -66,7 +66,7 @@ export const SysTeamMember = ObjectSchema.create({
6666
locations: ['list_item'],
6767
type: 'api',
6868
target: '/api/v1/auth/organization/remove-team-member',
69-
visible: 'features.organization != false',
69+
requiresFeature: 'organization',
7070
confirmText: 'Remove this user from the team? They will lose any team-scoped access.',
7171
successMessage: 'Team member removed',
7272
refreshAfter: true,

0 commit comments

Comments
 (0)