diff --git a/.changeset/24384-platform-objects-org-admin-reach.md b/.changeset/24384-platform-objects-org-admin-reach.md new file mode 100644 index 00000000000..93c4d4456e9 --- /dev/null +++ b/.changeset/24384-platform-objects-org-admin-reach.md @@ -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) diff --git a/.changeset/24384-spec-org-reach-rows.md b/.changeset/24384-spec-org-reach-rows.md new file mode 100644 index 00000000000..00ee6374306 --- /dev/null +++ b/.changeset/24384-spec-org-reach-rows.md @@ -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) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 0a19bf0ff8e..8b23b5c03d2 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -55,7 +55,7 @@ const result = ActionSchema.parse(data); | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; 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. | @@ -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']` diff --git a/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts b/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts index f451d15402f..e2202efa89e 100644 --- a/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts +++ b/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts @@ -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 @@ -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 { @@ -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); }); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 16a3dce6579..06e71a1a9a6 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -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: [ @@ -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, @@ -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'], @@ -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: [ diff --git a/packages/platform-objects/src/pages/sys-organization.page.ts b/packages/platform-objects/src/pages/sys-organization.page.ts index 2f56173ff2c..45dcaf3fd65 100644 --- a/packages/platform-objects/src/pages/sys-organization.page.ts +++ b/packages/platform-objects/src/pages/sys-organization.page.ts @@ -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 diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 1bfff092e6b..5ef22b85f72 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -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], diff --git a/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts b/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts new file mode 100644 index 00000000000..bde6366eeb6 --- /dev/null +++ b/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The organisation's OWN admin actions follow the caller's membership grade — + * measured on a booted multi-organization showcase, on the wire, from the same + * three things the console combines. + * + * ## The defect this pins shut + * + * `sys_organization`'s rename, edit and delete actions were gated on + * `multiOrgEnabled` alone. Only the invitation carried a grade gate, so a plain + * member was offered "Rename Organization" in the record header and "Edit" / + * "Delete" on the list row, and better-auth's `organization/update` and + * `organization/delete` refused every click with 403. They now declare + * `requiresMembershipReach` from the reach table's `update_organization` + * (owner, admin) and `delete_organization` (owner) rows — the rows + * plugin-auth's `membership-reach-table.test.ts` pins equal to the door. + * + * ## The one action outside it: `change_slug` + * + * Its target, `/api/v1/cloud/organizations/{id}/change-slug`, is served only + * by the cloud orchestrator; on this composition it answers + * `404 ENDPOINT_NOT_FOUND`. Where it is offered, and to whom, waits on a + * decision about that action's shape (a door outside better-auth, so no reach + * row describes it). It is held out of the graded set and out of the door + * probe below, by name, so the day it changes this file is edited with it. + * + * ## What this measures + * + * For an owner who created the organisation through the door, an `admin` and a + * plain `member` of it, each with it active, it reads the SERVED session face + * (`GET /auth/get-session`, the `positions` bound as `current_user`), the + * SERVED flags (`GET /auth/config`) and the SERVED `sys_organization` metadata + * (`GET /meta/object/sys_organization`), and evaluates each served `visible` + * with `@objectstack/formula`'s `celEngine`, the console's engine. The + * expected sets are spelled out, not computed from the table, and both halves + * are asserted per grade — what it IS offered and what it is NOT. + * + * The door probes then show the verdicts are the door's own: each refusal is + * the vendor's 403 with its own code, each admission a 200 that took effect. + * And every other `type: 'api'` action the record serves is called once at its + * own target: none answers the router's `404 ENDPOINT_NOT_FOUND`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { celEngine } from '@objectstack/formula'; + +const SYSTEM_CTX = { isSystem: true }; +const PASSWORD = 'OrgReach!Pass123'; + +type Grade = 'owner' | 'admin' | 'member'; +type ServedAction = { name: string; type?: string; target?: string; visible?: unknown }; + +/** Every grade may switch to, leave, or create an organisation: no grade term. */ +const EVERY_GRADE = ['create_organization', 'set_active_organization', 'leave_organization'] as const; +/** `organization/invite-member`: owner, admin and delegated_admin. */ +const INVITE = ['invite_user'] as const; +/** `organization/update`: owner and admin. */ +const UPDATE = ['update_organization', 'rename_organization'] as const; +/** `organization/delete`: the owner alone. */ +const DELETE = ['delete_organization'] as const; +const ALL = [...EVERY_GRADE, ...INVITE, ...UPDATE, ...DELETE]; +/** Served, and held out of the graded set — see the docblock. */ +const UNDECIDED = ['change_slug'] as const; + +const EXPECTED: Record = { + owner: ALL, + admin: [...EVERY_GRADE, ...INVITE, ...UPDATE], + member: [...EVERY_GRADE], +}; + +const PROJECTED: Record = { owner: 'org_owner', admin: 'org_admin', member: 'org_member' }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +describe('sys_organization admin actions follow the membership grade, and every one has a door', () => { + let stack: VerifyStack; + let ql: any; + let features: Record; + const tokens = {} as Record; + const userIds = {} as Record; + const sessionUser = {} as Record>; + const served = {} as Record>; + /** The organisation the grades are measured in, and a second one the delete probes may destroy. */ + const org = { main: { id: '', row: {} as Record }, doomed: { id: '' } }; + + const createOrg = async (token: string, name: string, slug: string): Promise => { + const res = await stack.apiAs(token, 'POST', '/auth/organization/create', { name, slug }); + expect(res.status, await res.clone().text()).toBe(200); + return String(((await res.json()) as { id: string }).id); + }; + const join = async (grade: Exclude, organizationId: string) => { + await ql.insert('sys_member', { user_id: userIds[grade], organization_id: organizationId, role: grade }, { context: SYSTEM_CTX }); + }; + + beforeAll(async () => { + stack = await bootShowcase({ multiTenant: 'posture-only' }); + ql = await stack.kernel.getServiceAsync('objectql'); + + for (const grade of Object.keys(PROJECTED) as Grade[]) { + const email = `org-reach.${grade}@verify.test`; + tokens[grade] = await stack.signUp(email, PASSWORD, `Org reach ${grade}`); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + userIds[grade] = String(user.id); + } + + // The owner creates both organisations through the door (better-auth makes + // the creator its `owner`); the other two join in system context — the only + // writer better-auth-managed tables accept (ADR-0092). + org.main.id = await createOrg(tokens.owner, 'Org Reach', 'org-reach'); + org.doomed.id = await createOrg(tokens.owner, 'Org Reach Doomed', 'org-reach-doomed'); + for (const id of [org.main.id, org.doomed.id]) { + await join('admin', id); + await join('member', id); + } + for (const grade of Object.keys(PROJECTED) as Grade[]) { + const res = await stack.apiAs(tokens[grade], 'POST', '/auth/organization/set-active', { organizationId: org.main.id }); + expect(res.status, `${grade} activates the organisation: ${await res.clone().text()}`).toBe(200); + } + [org.main.row] = await findRows(ql, 'sys_organization', { id: org.main.id }, 1); + + const config = await stack.apiAs(tokens.member, 'GET', '/auth/config'); + expect(config.status).toBe(200); + features = ((await config.json()) as { data: { features: Record } }).data.features; + + for (const grade of Object.keys(PROJECTED) as Grade[]) { + const session = await stack.apiAs(tokens[grade], 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + sessionUser[grade] = ((await session.json()) as { user: Record }).user; + const meta = await stack.apiAs(tokens[grade], 'GET', '/meta/object/sys_organization'); + expect(meta.status, `${grade} reads /meta/object/sys_organization`).toBe(200); + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + served[grade] = new Map((body.item?.actions ?? []).map((a) => [a.name, a])); + } + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + /** What the console offers: the action served to THAT principal, its predicate admitting it. */ + const offered = (grade: Grade, name: string): boolean => { + const action = served[grade].get(name); + if (!action) return false; + expect(action.visible, `${name} serves one predicate to every grade`).toEqual(served.owner.get(name)?.visible); + if (action.visible === undefined) return true; + const result = celEngine.evaluate(action.visible as never, { + record: org.main.row, + user: sessionUser[grade] as never, + extra: { features }, + }); + if (!result.ok) throw new Error(`${name} faulted for ${grade}: ${JSON.stringify(result)}`); + return result.value === true; + }; + + it('the composition is multi-organization, and each session carries its own grade alone', () => { + // The feature half of every composed predicate is ON, so each verdict + // below is decided by the grade term. + expect(features.multiOrgEnabled).toBe(true); + expect(features.organization).toBe(true); + for (const [grade, projected] of Object.entries(PROJECTED) as Array<[Grade, string]>) { + const positions = sessionUser[grade].positions as string[]; + expect(positions, grade).toContain(projected); + for (const other of Object.values(PROJECTED).filter((p) => p !== projected)) { + expect(positions, `${grade} must not carry ${other}`).not.toContain(other); + } + } + }); + + it('every grade is served the same action set', () => { + for (const grade of Object.keys(PROJECTED) as Grade[]) { + expect([...served[grade].keys()].sort(), grade).toEqual([...ALL, ...UNDECIDED].sort()); + } + }); + + it.each(Object.keys(EXPECTED) as Grade[])('%s is offered exactly the organisation actions its grade reaches', (grade) => { + expect(ALL.filter((name) => offered(grade, name))).toEqual(ALL.filter((name) => EXPECTED[grade].includes(name))); + }); + + it('each verdict matches the door: hidden means refused, shown means admitted', async () => { + /** A door refusal: 403 with the vendor's own code — never a bare status. */ + const refused = async (res: Response, code: string) => { + expect(res.status, await res.clone().text()).toBe(403); + expect(((await res.json()) as { code?: string }).code).toBe(code); + }; + + // organization/update: the plain member is refused; the admin and the owner are admitted. + await refused(await stack.apiAs(tokens.member, 'POST', '/auth/organization/update', { + organizationId: org.main.id, data: { name: 'Renamed By Member' }, + }), 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION'); + for (const grade of ['admin', 'owner'] as const) { + const name = `Renamed By ${grade}`; + const res = await stack.apiAs(tokens[grade], 'POST', '/auth/organization/update', { organizationId: org.main.id, data: { name } }); + expect(res.status, await res.clone().text()).toBe(200); + const [row] = await findRows(ql, 'sys_organization', { id: org.main.id }, 1); + expect(row.name).toBe(name); + } + + // organization/delete: the plain member and the admin are refused; the owner is admitted. + for (const grade of ['member', 'admin'] as const) { + await refused( + await stack.apiAs(tokens[grade], 'POST', '/auth/organization/delete', { organizationId: org.doomed.id }), + 'YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION', + ); + } + expect(await findRows(ql, 'sys_organization', { id: org.doomed.id }, 1)).toHaveLength(1); + const deleted = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/delete', { organizationId: org.doomed.id }); + expect(deleted.status, await deleted.clone().text()).toBe(200); + expect(await findRows(ql, 'sys_organization', { id: org.doomed.id }, 1)).toEqual([]); + }, 60_000); + + it('every graded api action the record serves reaches a door this composition mounts — none answers ENDPOINT_NOT_FOUND', async () => { + const held: readonly string[] = UNDECIDED; + const apiActions = [...served.owner.values()].filter((a) => a.type === 'api' && !held.includes(a.name)); + // The plain member and an organisation id no row carries: every door + // refuses before it writes anything, and a refusal from the door is the + // door's own answer, not the router's. + const answers: Array<{ action: string; status: number; code?: string }> = []; + for (const action of apiActions) { + const target = String(action.target); + expect(target.startsWith('/api/v1/'), `${action.name}: ${target}`).toBe(true); + const path = target.slice('/api/v1'.length).replace('{id}', 'org_does_not_exist'); + const res = await stack.apiAs(tokens.member, 'POST', path, { organizationId: 'org_does_not_exist' }); + const body = (await res.clone().json().catch(() => ({}))) as { code?: string; error?: { code?: string } }; + answers.push({ action: action.name, status: res.status, code: body.error?.code ?? body.code }); + } + expect(answers.filter((a) => a.status === 404), JSON.stringify(answers)).toEqual([]); + expect(answers.map((a) => a.action).sort()).toEqual([...ALL].sort()); + }, 60_000); +}); diff --git a/packages/spec/src/identity/membership-reach.ts b/packages/spec/src/identity/membership-reach.ts index ca5952ca04f..3f5c8ae7ecf 100644 --- a/packages/spec/src/identity/membership-reach.ts +++ b/packages/spec/src/identity/membership-reach.ts @@ -147,6 +147,16 @@ export const MEMBERSHIP_REACH = { statement: { resource: 'member', action: 'delete' }, grades: OWNER_ADMIN, }, + update_organization: { + endpoint: '/organization/update', + statement: { resource: 'organization', action: 'update' }, + grades: OWNER_ADMIN, + }, + delete_organization: { + endpoint: '/organization/delete', + statement: { resource: 'organization', action: 'delete' }, + grades: [MEMBERSHIP_ROLE_OWNER], + }, } as const satisfies Record; export type MembershipReachName = keyof typeof MEMBERSHIP_REACH;