Skip to content

Commit f573976

Browse files
fix(platform-objects): sys_organization admin actions follow the membership reach (update/delete rows) (#24399)
Fixes #24384 #24318 remains open: this PR does not change `change_slug`. Its shape needs a decision, set out under "#24318" below. Clause-②: yes (widening) (`@objectstack/spec`'s published reach table gains two rows, while the console offers fewer actions; `CONTRACT_REVIEW_TIER` record 6255881407, PASS, on the published-schema face) ## What changes - `packages/spec/src/identity/membership-reach.ts`: the reach table (`MEMBERSHIP_REACH`) gains two rows, both read off better-auth's own default role statements: - `update_organization`: `/organization/update`, statement `organization: ['update']`, grades owner and admin. - `delete_organization`: `/organization/delete`, statement `organization: ['delete']`, grade owner only. - plugin-auth's `membership-reach-table.test.ts` iterates every row, so both new rows are held equal to the installed vendor door with no edit to that test: the endpoint's `hasPermission` statement, and the grades whose registered statements pass it. - `packages/platform-objects/src/identity/sys-organization.object.ts`: - `update_organization` and `rename_organization` declare `requiresMembershipReach: 'update_organization'`. - `delete_organization` declares `requiresMembershipReach: 'delete_organization'`. - `multiOrgEnabled` stays the last term of each predicate. `invite_user` is unchanged. `leave_organization`, `set_active_organization`, `create_organization` and `change_slug` keep no grade term. - `packages/platform-objects/src/pages/sys-organization.page.ts`: the stale header-actions comment now names the header as it is laid out (fold-in note 6249926047). Comment only. - Pins: - `platform-objects.test.ts`: the lowering matrix's three rows now carry the grade term. - `sys-organization-rename-action.test.ts`: the rename's gate equals `update_organization`'s. - New `packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts` (below). - Generated: `content/docs/references/ui/action.mdx`, through `check:generated --fix`. - Changesets: `@objectstack/spec` minor, `@objectstack/platform-objects` minor. No door changed, no error code was added, and `leave_organization` stays ungated. ## Measured: M1 before and after (booted showcase, `multiTenant: 'posture-only'`) The new dogfood file boots a multi-organization showcase. An owner creates the organisation through `organization/create`; an `admin` and a plain `member` join it, and each principal activates it. For each principal the file reads the served session (`positions`), the served `/auth/config` features (`multiOrgEnabled: true`) and the served `GET /meta/object/sys_organization`, then evaluates each served `visible` with `@objectstack/formula`'s `celEngine`. It then calls the doors. Offered per grade (the expected sets are written out in the test): | grade | before (tree `1eae21f`, dist built from it) | after | |:--|:--|:--| | owner | everything | everything | | admin | everything, **including Delete Organization** | create, set active, leave, invite, edit, rename | | member | create, set active, leave, **edit, rename, delete** | create, set active, leave | `change_slug` was offered to every grade before and is still offered to every grade (unchanged; see #24318). The doors' answers. Before this run they were NOT MEASURED; they are measured now, on the same boot: - `POST /auth/organization/update`: - member: `403 YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION`. - admin and owner: `200`, and the stored name changed. - `POST /auth/organization/delete`: - member and admin: `403 YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION`, and the row still exists. - owner: `200`, and the row is gone. - Every `type: 'api'` target the record serves, called once as the plain member with an unknown `organizationId`: | action | answer | |:--|:--| | `create_organization` | `400 VALIDATION_ERROR` | | `update_organization` | `400 VALIDATION_ERROR` | | `delete_organization` | `400 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION` | | `set_active_organization` | `403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION` | | `invite_user` | `400 VALIDATION_ERROR` | | `leave_organization` | `400 MEMBER_NOT_FOUND` | | `rename_organization` | `400 VALIDATION_ERROR` | | **`change_slug`** | **`404 ENDPOINT_NOT_FOUND`** | Runs: - Before (fix not applied): the new file fails 4 of 7. Admin is offered delete; member is offered update, rename and delete; `change_slug` answers 404. The two door tests pass. - After, on `dde114aba`: 3 files (`org-record-admin-reach`, `org-admin-affordance-reach`, `organization-rename-door`), 23 of 23 pass. The final file holds `change_slug` out by name, as `UNDECIDED`, so the day its shape changes, the test changes with it. ## Ablations (one per mechanism, `scripts/ablation-replace.mjs`, dist preflighted) Mutation landing, restore and dist presence were checked on disk for each. 1. **The gate.** Delete `requiresMembershipReach: 'delete_organization'` from the object (anchor 1 → 0), then rebuild `platform-objects`. - `ablation-dist-preflight --absent` passes. - The `delete_organization` row of `platform-objects.test.ts` turns red: `'features.multiOrgEnabled != false'` against the composed predicate. - The dogfood file turns red, 2 failures: admin and member are now offered delete. - Restore: blob equals HEAD and `git diff HEAD` is empty. The rebuilt dist's preflight finds the marker in 6 files. - This ablation ran while `change_slug` was briefly removed on this branch; the delete gate it mutates is byte-identical on the final head. 2. **The row.** Widen `delete_organization`'s grades to `OWNER_ADMIN`, then rebuild `spec`. - plugin-auth's `membership-reach-table.test.ts` turns red: `delete_organization: the grades equal …`, `[ 'owner', 'admin' ]` against the door's `[ 'owner' ]`, 1 of 27. - Restore: blob equals HEAD. After the rebuild, the dist reads `["owner"]`. 3. **`change_slug`'s 404.** The before run is its reading. This PR does not change it. ## Verification record (head `dde114aba`, after merging `origin/main` `584b4225e`) - `pnpm --filter @objectstack/spec test`: 695 files, 21238 passed, 1 todo. - `pnpm --filter @objectstack/platform-objects test`: 76 files, 1176 passed. - `pnpm --filter @objectstack/plugin-auth exec vitest run src/membership-reach-table.test.ts src/public-feature-registry.test.ts`: 32 passed. - Dogfood: 3 files, 23 passed, as above. - Typecheck exit codes: spec 0, platform-objects 0, dogfood 0. - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date, after a rebuild on the merged tree. - `node scripts/pm/dispatch-gates.mjs --commands` on this head: 112 commands, each run and each exit captured before any pipe; all 112 exit 0. - Two first refused with exit 3, PREREQUISITE NOT MET (unbuilt packages): `check:skill-examples` and `check:dual-build-cjs-loads`. After building the named packages, both exit 0. - `check:slot-lookup` was cut off by the batch timeout once; it ran alone and exits 0. - `--ran` reconciliation: 112 derived, 112 run, 0 NOT-MEASURED (a derived zero, since every line carries its exit code). - Local-only families: `check:dev-prereqs` 0, `check:skill-frame-freshness` 0. ## #24318: why `change_slug` is not changed here Measured: - No package in this repository serves `/api/v1/cloud/organizations/{id}/change-slug`. On this composition it answers `404 ENDPOINT_NOT_FOUND`. - No existing public signal tells a composition with the orchestrator apart from one without it. `PUBLIC_AUTH_FEATURES` carries 11 keys, none of them about cloud, and nothing else in `/auth/config` does either. - **Measured probe:** the showcase booted with one extra top-level action, `objectName: 'sys_organization'`, `locations: ['record_header']`. The served `GET /meta/object/sys_organization` did not carry it; it listed the platform object's own actions only. `mergeActionsIntoObjects` (`stack.zod.ts`) attaches a bound action only to objects in the same stack. So the "cloud declares its own action" shape has no working route today: removing `change_slug` from the platform object would take the button off cloud's page, and cloud would have no declared way to put it back. `objectExtensions` refuses `actions` by design. The three shapes on the card, by the four axes: - **(A) A new `PUBLIC_AUTH_FEATURES` key that only the orchestrator's composition sets.** Business need: the one consumer is cloud. Long term: it puts a cloud-only key into the open-source contract, and it needs a plugin-auth producer and a drift-guard row as well as the spec row. AI safety: it is loud only if cloud remembers to set it. Startup scope: it is a new gate, which defaults to no unless the maintainer names it. - **(B) The composition that serves the door declares the action, and the platform object stops declaring it.** It matches "one route, one owner" and adds no key. But it needs a platform mechanism that attaches a bound action to an object another package owns, which does not exist (the probe above). Without that, cloud loses the button. - **(C) The open-source composition serves the route.** That would widen scope into the orchestrator's sys_domain and reservation semantics. Startup scope: no. Recommendation: (B), with the attach mechanism as its prerequisite. The maintainer picks, because (A) adds a gate and (B) adds a platform mechanism. Until then `change_slug` keeps `multiOrgEnabled` alone. It gets no reach term either: its door is cloud's `org-admin`, outside better-auth, so no reach row describes it. ## Acceptance notes - The two reach rows are a cross-domain exception, carried under the claim: `packages/spec` is `domain:spec`'s surface, declared to it on #6017 by the seat. - `set_active_organization`, `leave_organization` and `create_organization` carry no grade term. Every grade may switch, leave or create; the doors were measured answering their own refusals, not a 404. --- _Generated by [Claude Code](https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d79dc48 commit f573976

9 files changed

Lines changed: 288 additions & 11 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
`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:
6+
7+
- `update_organization` ("Edit Organization", list row) and `rename_organization` ("Rename Organization", record header and list row) declare `requiresMembershipReach: 'update_organization'`: owner and admin.
8+
- `delete_organization` ("Delete Organization", list row) declares `requiresMembershipReach: 'delete_organization'`: the owner alone.
9+
10+
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.
11+
12+
Clause-②: yes (widening)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`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.
6+
7+
Clause-②: yes (widening)

‎content/docs/references/ui/action.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const result = ActionSchema.parse(data);
5555
| **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). |
5656
| **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. |
5757
| **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. |
58-
| **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. |
58+
| **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. |
5959
| **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. |
6060
| **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. |
6161
| **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. |
@@ -91,6 +91,8 @@ const result = ActionSchema.parse(data);
9191
* `remove_team`
9292
* `add_team_member`
9393
* `remove_team_member`
94+
* `update_organization`
95+
* `delete_organization`
9496

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

‎packages/platform-objects/src/identity/sys-organization-rename-action.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
// - the new action's declaration: its params are exactly `name` and `logo`,
1313
// it sits at `record_header` and `list_item`, it targets better-auth's
1414
// `organization/update` exactly as `update_organization` does, and it is
15-
// gated on `multiOrgEnabled` like every sibling organization action;
15+
// gated like it: on `multiOrgEnabled` and on the grades that reach
16+
// `organization/update` (owner, admin);
1617
// - one door per field on the record header: no `record_header` action that
1718
// targets `organization/update` carries a `slug` param;
1819
// - the header order: the rename takes an inline slot and `change_slug` folds
@@ -25,6 +26,7 @@
2526
// `packages/qa/dogfood`.
2627
import { describe, expect, it } from 'vitest';
2728
import { featureGatePredicate } from '@objectstack/spec/kernel';
29+
import { membershipReachPredicate } from '@objectstack/spec/identity';
2830
import { SysOrganization } from './sys-organization.object.js';
2931

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

100-
it('is gated on multiOrgEnabled, the same lowered predicate as update_organization', () => {
101-
const gate = featureGatePredicate('multiOrgEnabled');
102+
it('is gated on the update reach and multiOrgEnabled, the same lowered predicate as update_organization', () => {
103+
const gate = `(${membershipReachPredicate('update_organization')}) && ${featureGatePredicate('multiOrgEnabled')}`;
102104
expect(action('rename_organization').visible?.source).toBe(gate);
103105
expect(action('update_organization').visible?.source).toBe(gate);
104106
});

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,10 @@ export const SysOrganization = ObjectSchema.create({
9797
bodyShape: { wrap: 'data' },
9898
// Org-admin actions are multi-org-only; hide them in single-org for
9999
// consistency with `create_organization` (the org list is empty there,
100-
// but this also guards direct record-URL access).
100+
// but this also guards direct record-URL access). The grades that reach
101+
// `organization/update` are owner and admin (ADR-0108 D1).
101102
requiresFeature: 'multiOrgEnabled',
103+
requiresMembershipReach: 'update_organization',
102104
successMessage: 'Organization updated',
103105
refreshAfter: true,
104106
params: [
@@ -118,6 +120,8 @@ export const SysOrganization = ObjectSchema.create({
118120
target: '/api/v1/auth/organization/delete',
119121
recordIdParam: 'organizationId',
120122
requiresFeature: 'multiOrgEnabled',
123+
// `organization/delete` admits the owner alone (ADR-0108 D1).
124+
requiresMembershipReach: 'delete_organization',
121125
confirmText: 'Delete this organization? All members will lose access immediately. This cannot be undone.',
122126
successMessage: 'Organization deleted',
123127
refreshAfter: true,
@@ -200,7 +204,7 @@ export const SysOrganization = ObjectSchema.create({
200204
// one folds into the `⋯` overflow's destructive band (objectui's
201205
// page:header split, default `maxVisible` 3), with `change_slug` in the
202206
// overflow above it. Without it, registration order kept it inline as
203-
// the third button.
207+
// the third button. Ungated by grade: every grade may leave.
204208
order: 1,
205209
mode: 'custom',
206210
locations: ['list_item', 'record_header'],
@@ -242,6 +246,8 @@ export const SysOrganization = ObjectSchema.create({
242246
recordIdParam: 'organizationId',
243247
bodyShape: { wrap: 'data' },
244248
requiresFeature: 'multiOrgEnabled',
249+
// The same door as `update_organization`, so the same grades.
250+
requiresMembershipReach: 'update_organization',
245251
successMessage: 'Organization renamed',
246252
refreshAfter: true,
247253
params: [

‎packages/platform-objects/src/pages/sys-organization.page.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import type { Page } from '@objectstack/spec/ui';
1212
* The page is `kind: 'slotted'` and overrides only the `tabs` slot —
1313
* header, actions, highlights, details and discussion fall through to
1414
* the synthesized default, so the organization's own fields and the
15-
* existing record-header actions (Set Active, Edit, Delete, Leave) are
16-
* preserved.
15+
* object's `record_header` actions (Invite User, Set Active and Rename
16+
* Organization inline, Change Slug and Leave Organization in the overflow —
17+
* Edit and Delete are row actions only) are preserved.
1718
*
1819
* Each tab is a `record:related_list` over a child object that already
1920
* has `organization_id` as a `Field.lookup('sys_organization')` — the

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -585,9 +585,11 @@ describe('feature-gate lowering matrix (#2874)', () => {
585585

586586
const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [
587587
['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG],
588-
['SysOrganization', SysOrganization, 'update_organization', MULTI_ORG],
589-
['SysOrganization', SysOrganization, 'rename_organization', MULTI_ORG],
590-
['SysOrganization', SysOrganization, 'delete_organization', MULTI_ORG],
588+
// Their doors are better-auth's `organization/update` (owner, admin) and
589+
// `organization/delete` (owner), so the grade term leads the feature gate.
590+
['SysOrganization', SysOrganization, 'update_organization', `${ADMINS} && ${MULTI_ORG}`],
591+
['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${MULTI_ORG}`],
592+
['SysOrganization', SysOrganization, 'delete_organization', `(${OWNER}) && ${MULTI_ORG}`],
591593
['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG],
592594
['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG],
593595
['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG],

0 commit comments

Comments
 (0)