Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/24384-platform-objects-org-admin-reach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/platform-objects': minor
---

`sys_organization`'s organisation-admin actions are now offered only to the membership grades whose requests the door accepts. Before, they were gated on `multiOrgEnabled` alone, so a plain member saw them, and better-auth refused every click with 403:

- `update_organization` ("Edit Organization", list row) and `rename_organization` ("Rename Organization", record header and list row) declare `requiresMembershipReach: 'update_organization'`: owner and admin.
- `delete_organization` ("Delete Organization", list row) declares `requiresMembershipReach: 'delete_organization'`: the owner alone.

The `multiOrgEnabled` gate stays as the last term of each predicate. `invite_user` is unchanged, and `set_active_organization`, `leave_organization`, `create_organization` and `change_slug` stay as they were.

Clause-②: yes (widening)
7 changes: 7 additions & 0 deletions .changeset/24384-spec-org-reach-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': minor
---

`MEMBERSHIP_REACH` (`@objectstack/spec/identity`) gains two rows, so `requiresMembershipReach` accepts two more names: `update_organization` (better-auth's `/organization/update`, statement `organization: ['update']`, reached by `owner` and `admin`) and `delete_organization` (`/organization/delete`, statement `organization: ['delete']`, reached by `owner` alone). Both are read off better-auth's own default role statements, like the existing rows, and plugin-auth's reach-table test holds each equal to the installed vendor door. An action declaring `requiresMembershipReach: 'update_organization'` is lowered to `visible: "'org_owner' in current_user.positions || 'org_admin' in current_user.positions"`; `'delete_organization'` lowers to `"'org_owner' in current_user.positions"`. Existing rows and their lowering are unchanged.

Clause-②: yes (widening)
4 changes: 3 additions & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +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. |
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 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; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down Expand Up @@ -91,6 +91,8 @@ const result = ActionSchema.parse(data);
* `remove_team`
* `add_team_member`
* `remove_team_member`
* `update_organization`
* `delete_organization`

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
// - the new action's declaration: its params are exactly `name` and `logo`,
// it sits at `record_header` and `list_item`, it targets better-auth's
// `organization/update` exactly as `update_organization` does, and it is
// gated on `multiOrgEnabled` like every sibling organization action;
// gated like it: on `multiOrgEnabled` and on the grades that reach
// `organization/update` (owner, admin);
// - one door per field on the record header: no `record_header` action that
// targets `organization/update` carries a `slug` param;
// - the header order: the rename takes an inline slot and `change_slug` folds
Expand All @@ -25,6 +26,7 @@
// `packages/qa/dogfood`.
import { describe, expect, it } from 'vitest';
import { featureGatePredicate } from '@objectstack/spec/kernel';
import { membershipReachPredicate } from '@objectstack/spec/identity';
import { SysOrganization } from './sys-organization.object.js';

interface DeclaredParam {
Expand Down Expand Up @@ -97,8 +99,8 @@ describe('sys_organization.rename_organization: the declaration', () => {
.toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape });
});

it('is gated on multiOrgEnabled, the same lowered predicate as update_organization', () => {
const gate = featureGatePredicate('multiOrgEnabled');
it('is gated on the update reach and multiOrgEnabled, the same lowered predicate as update_organization', () => {
const gate = `(${membershipReachPredicate('update_organization')}) && ${featureGatePredicate('multiOrgEnabled')}`;
expect(action('rename_organization').visible?.source).toBe(gate);
expect(action('update_organization').visible?.source).toBe(gate);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ export const SysOrganization = ObjectSchema.create({
bodyShape: { wrap: 'data' },
// 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).
// but this also guards direct record-URL access). The grades that reach
// `organization/update` are owner and admin (ADR-0108 D1).
requiresFeature: 'multiOrgEnabled',
requiresMembershipReach: 'update_organization',
successMessage: 'Organization updated',
refreshAfter: true,
params: [
Expand All @@ -118,6 +120,8 @@ export const SysOrganization = ObjectSchema.create({
target: '/api/v1/auth/organization/delete',
recordIdParam: 'organizationId',
requiresFeature: 'multiOrgEnabled',
// `organization/delete` admits the owner alone (ADR-0108 D1).
requiresMembershipReach: 'delete_organization',
confirmText: 'Delete this organization? All members will lose access immediately. This cannot be undone.',
successMessage: 'Organization deleted',
refreshAfter: true,
Expand Down Expand Up @@ -200,7 +204,7 @@ export const SysOrganization = ObjectSchema.create({
// one folds into the `⋯` overflow's destructive band (objectui's
// page:header split, default `maxVisible` 3), with `change_slug` in the
// overflow above it. Without it, registration order kept it inline as
// the third button.
// the third button. Ungated by grade: every grade may leave.
order: 1,
mode: 'custom',
locations: ['list_item', 'record_header'],
Expand Down Expand Up @@ -242,6 +246,8 @@ export const SysOrganization = ObjectSchema.create({
recordIdParam: 'organizationId',
bodyShape: { wrap: 'data' },
requiresFeature: 'multiOrgEnabled',
// The same door as `update_organization`, so the same grades.
requiresMembershipReach: 'update_organization',
successMessage: 'Organization renamed',
refreshAfter: true,
params: [
Expand Down
5 changes: 3 additions & 2 deletions packages/platform-objects/src/pages/sys-organization.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import type { Page } from '@objectstack/spec/ui';
* The page is `kind: 'slotted'` and overrides only the `tabs` slot —
* header, actions, highlights, details and discussion fall through to
* the synthesized default, so the organization's own fields and the
* existing record-header actions (Set Active, Edit, Delete, Leave) are
* preserved.
* object's `record_header` actions (Invite User, Set Active and Rename
* Organization inline, Change Slug and Leave Organization in the overflow —
* Edit and Delete are row actions only) are preserved.
*
* Each tab is a `record:related_list` over a child object that already
* has `organization_id` as a `Field.lookup('sys_organization')` — the
Expand Down
8 changes: 5 additions & 3 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,9 +585,11 @@ describe('feature-gate lowering matrix (#2874)', () => {

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, 'rename_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'delete_organization', MULTI_ORG],
// Their doors are better-auth's `organization/update` (owner, admin) and
// `organization/delete` (owner), so the grade term leads the feature gate.
['SysOrganization', SysOrganization, 'update_organization', `${ADMINS} && ${MULTI_ORG}`],
['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${MULTI_ORG}`],
['SysOrganization', SysOrganization, 'delete_organization', `(${OWNER}) && ${MULTI_ORG}`],
['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG],
Expand Down
Loading
Loading