Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/21795-platform-objects-org-admin-actions-by-grade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/platform-objects': patch
---

The organization's member, invitation and team actions are offered only to the membership grades the server admits. A plain member no longer sees "Invite User", "Change Role", "Remove Member", "Cancel Invitation", "Create Team" and the rest, each of which the server refused with 403.

- `invite_user` (on the Users, Members and Invitations lists) and `resend_invitation`: owner, admin and delegated_admin.
- `update_member_role`, `remove_member`, `cancel_invitation`, `create_team`, `update_team`, `remove_team`, `add_team_member` and `remove_team_member`: owner and admin.
- `transfer_ownership`: the owner alone, on a non-owner row.
- `add_member` is unchanged. Its door is platform-admin standing, not a membership grade.

Each action declares `requiresMembershipReach` from `@objectstack/spec`, which is lowered into its `visible` predicate.
13 changes: 13 additions & 0 deletions .changeset/21795-spec-membership-reach-action-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
---

An action can declare which organization membership grades it is offered to: `requiresMembershipReach` names a row of the new `MEMBERSHIP_REACH` table and is lowered at parse time into `visible`, the way `requiresFeature` is.

Clause-②: yes (widening)

- **`MEMBERSHIP_REACH`** (`@objectstack/spec/identity`) says which membership grades reach which better-auth organization endpoint. `invite_member` is reached by owner, admin and delegated_admin. `cancel_invitation`, `update_member_role`, `remove_member`, `create_team`, `update_team`, `remove_team`, `add_team_member` and `remove_team_member` are reached by owner and admin. `transfer_ownership` (setting the creator role on a member) is reached by the owner alone. The rows are read off better-auth's own access-control statements plus the `delegated_admin` registration, and plugin-auth pins them equal to the door. It is reach, not authority (ADR-0108 D1): a fourth fact beside the membership names, the administrative-grade rule and the identity projection, and merged into none of them. Also exported: `MEMBERSHIP_REACH_NAMES`, `membershipReachPredicate`, `lowerRequiresMembershipReach`, and the `MembershipReachEntry`, `MembershipReachName` and `MembershipReachStatement` types.
- **`ActionSchema.requiresMembershipReach`** is optional and enum-checked against the table's row names. At parse time it becomes one `'<name>' in current_user.positions` term per grade, in the names `mapMembershipRole` projects them to (`org_owner`, `org_admin`, `delegated_admin`). The terms are AND-composed with an explicit `visible`, and the key is stripped from the parsed output. It composes ahead of `requiresFeature`, so a feature gate stays the last term. `visible: false`, a non-CEL or AST-only `visible`, and a blank `source` are refused at parse time, at the key.
- It is UI courtesy, not authorization: the endpoint's own door stays the authority. The capability channel (`current_user.can`) is unchanged and carries no grade (ADR-0108).

Nothing that parsed before is refused now, and an action without the key lowers exactly as before.
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ const result = ApiMethod.parse(data);
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
Expand Down
14 changes: 14 additions & 0 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const result = ActionSchema.parse(data);
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +2 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
Expand All @@ -78,6 +79,19 @@ const result = ActionSchema.parse(data);
| **_packageVersion** | `string` | optional | Owning package version. |
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |

### Allowed Values: `Action.requiresMembershipReach`

* `invite_member`
* `cancel_invitation`
* `update_member_role`
* `transfer_ownership`
* `remove_member`
* `create_team`
* `update_team`
* `remove_team`
* `add_team_member`
* `remove_team_member`

### Nested Shape: `Action.body[language='expression']`

L1 expression body — pure formula, no IO
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,11 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
// [#21095] 48 → 49: `outcomeMessages` joined the accepted set, and it
// is named in the sample below so the served schema is held to
// carrying it, not merely to having one more key than before.
expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(49);
// [#21795] 49 → 50: `requiresMembershipReach` joined the accepted set.
expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(50);
// A sample an author would actually address, and the one #17500's
// repeater titles need a node to sit on.
for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages']) {
for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) {
expect(Object.keys(properties)).toContain(key);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Requires Feature",
helpText: "Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key."
},
requiresMembershipReach: {
label: "Requires Membership Reach",
helpText: "The organization endpoint this action calls, so it is offered only to members whose grade reaches that endpoint. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key."
},
requiredPermissions: {
label: "Required Permissions",
helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Requiere función",
helpText: "Indicador público de función de autenticación que condiciona esta acción. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave."
},
requiresMembershipReach: {
label: "Requiere alcance de membresía",
helpText: "El endpoint de la organización al que llama esta acción, de modo que solo se ofrece a los miembros cuyo grado alcanza ese endpoint. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave."
},
requiredPermissions: {
label: "Permisos requeridos",
helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener, todas las enumeradas, para invocar esta acción (ADR-0066 D4). La ruta de acciones de la plataforma rechaza a cualquier otro con 403 (acciones script, flow y modal, y la vía MCP/IA), y se le oculta el botón. Una acción de type api llama directamente a su endpoint, así que ese endpoint debe volver a comprobarlas."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "必要な認証機能",
helpText: "このアクションの表示可否を決める公開認証機能フラグ。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。"
},
requiresMembershipReach: {
label: "必要なメンバーシップ到達先",
helpText: "このアクションが呼び出す組織エンドポイント。メンバーシップのグレードがそのエンドポイントに到達できるメンバーにだけ表示されます。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。"
},
requiredPermissions: {
label: "必要な権限",
helpText: "このアクションを実行するために呼び出し元が保持すべき機能(権限セットの systemPermissions)。列挙したすべてが必要です(ADR-0066 D4)。それ以外の呼び出し元はプラットフォームのアクションルートで 403 として拒否され(script、flow、modal アクションと MCP/AI 経路)、ボタンも表示されません。type が api のアクションはエンドポイントを直接呼び出すため、そのエンドポイントで改めてチェックする必要があります。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1169,7 +1169,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// taking its label — authored in all three locales — out of the catalog.
// 660 since #21765: the object form offers `imageField` beside
// `nameField` — one new row label, authored in all three locales.
expect(translated.length, `${locale} positive control`).toBe(660);
// 661 since the action form offers `requiresMembershipReach` beside
// `requiresFeature` — one new row label, authored in all three locales.
expect(translated.length, `${locale} positive control`).toBe(661);
}
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
// all-three predicate returns 0 while the per-locale one returns 1, so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "所需认证特性",
helpText: "用于控制该动作是否出现的公共认证特性开关。它在解析时被降解进 `visible` 断言并从输出中移除,因此下游消费方永远看不到这个键。"
},
requiresMembershipReach: {
label: "所需成员等级可达端点",
helpText: "该动作调用的组织端点:只有成员等级可达该端点的成员才会看到它。它在解析时被降解进 `visible` 断言并从输出中移除,因此下游消费方永远看不到这个键。"
},
requiredPermissions: {
label: "所需权限",
helpText: "调用此动作必须持有的能力(权限集的 systemPermissions),列出的每一项都必须持有(ADR-0066 D4)。平台动作路由会以 403 拒绝其他调用方(script、flow 和 modal 动作,以及 MCP/AI 路径),并对他们隐藏按钮。type 为 api 的动作由浏览器直接调用其端点,因此该端点必须自行再次检查。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ import { SysUserPreference } from './sys-user-preference.object.js';
import { SysBusinessUnit } from './sys-business-unit.object.js';
import { SysBusinessUnitMember } from './sys-business-unit-member.object.js';

const USER = { id: 'u1', email: 'me@example.com' };
/**
* The principal binding. `positions` is always present on the user an action
* predicate sees (`EvalUserSchema` defaults it, and the console forwards
* `user.positions ?? []`), and the org-admin actions AND-compose a
* membership-grade term over it. An owner is admitted by every such term — the
* same reasoning as the all-on {@link FEATURES} below: a grade term that
* answered false would short-circuit the composed `&&` and hide the record half
* this sweep exists to test.
*/
const USER = { id: 'u1', email: 'me@example.com', positions: ['org_owner'] };

/**
* `defineObject` normalizes a CEL shorthand string into a `{dialect, source}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,13 @@ describe('invite_user — the three mirrors agree (#11544)', () => {
// survive (pinned in platform-objects.test.ts), so the gate is read from
// its lowered form. A mirror that lost the gate would render a button that
// 404s wherever the org capability is off.
expect(action(object, 'invite_user').visible?.source).toBe('features.organization != false');
// The grade gate composes AHEAD of it (`requiresMembershipReach:
// 'invite_member'`), so the three mirrors also agree on WHO is offered
// the button: owner, admin and delegated_admin — never a plain member.
expect(action(object, 'invite_user').visible?.source).toBe(
"('org_owner' in current_user.positions || 'org_admin' in current_user.positions"
+ " || 'delegated_admin' in current_user.positions) && features.organization != false",
);
});

it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => {
Expand Down
Loading
Loading