From 6b7edfe706ec9d8d13a09d09971028e45fc98f2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:13:54 +0000 Subject: [PATCH 1/7] test(dogfood): measure sys_organization admin actions per membership grade and their doors Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- .../org-record-admin-reach.dogfood.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts 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..1de76fe7142 --- /dev/null +++ b/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The organisation's OWN admin actions follow the caller's membership grade, + * and the organisation record offers no action whose door this composition + * does not serve — measured on a booted multi-organization showcase, on the + * wire, from the same three things the console combines. + * + * ## The two defects this pins shut + * + * 1. `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. + * 2. `change_slug` targeted `/api/v1/cloud/organizations/{id}/change-slug`, a + * route only the cloud orchestrator serves. Here, with multi-org on, the + * record header offered it and the click answered `404 ENDPOINT_NOT_FOUND`. + * The platform object no longer declares it: the composition that serves the + * door declares the action beside it. The open-source slug door is + * `update_organization`, better-auth's `organization/update`. + * + * ## 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 `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]; + +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, and change_slug is not in it', () => { + for (const grade of Object.keys(PROJECTED) as Grade[]) { + expect([...served[grade].keys()].sort(), grade).toEqual([...ALL].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 api action the record serves reaches a door this composition mounts — none answers ENDPOINT_NOT_FOUND', async () => { + const apiActions = [...served.owner.values()].filter((a) => a.type === 'api'); + expect(apiActions.map((a) => a.name).sort()).toEqual([...ALL].sort()); + // 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. + 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 } }; + expect( + { status: res.status, code: body.error?.code ?? body.code }, + `${action.name} → POST ${target}`, + ).not.toEqual({ status: 404, code: 'ENDPOINT_NOT_FOUND' }); + } + }, 60_000); +}); From 06514d5ac222d391d8e0fd7fae93c3472acbc6b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:24:15 +0000 Subject: [PATCH 2/7] test(dogfood): record every api action's door answer before asserting Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- .../dogfood/test/org-record-admin-reach.dogfood.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index 1de76fe7142..42745bdc6b7 100644 --- a/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts @@ -212,20 +212,19 @@ describe('sys_organization admin actions follow the membership grade, and every it('every api action the record serves reaches a door this composition mounts — none answers ENDPOINT_NOT_FOUND', async () => { const apiActions = [...served.owner.values()].filter((a) => a.type === 'api'); - expect(apiActions.map((a) => a.name).sort()).toEqual([...ALL].sort()); // 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 } }; - expect( - { status: res.status, code: body.error?.code ?? body.code }, - `${action.name} → POST ${target}`, - ).not.toEqual({ status: 404, code: 'ENDPOINT_NOT_FOUND' }); + 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); }); From 2eaee0ccdfa398a59e6245d2917b2a12fdf23898 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:27:20 +0000 Subject: [PATCH 3/7] fix(platform-objects): sys_organization admin actions follow the membership reach; change_slug leaves the platform object Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- .../confirm-question-carryover.test.ts | 8 ++- .../action-confirm-one-dialog.test.ts | 8 ++- .../sys-organization-rename-action.test.ts | 38 ++++++------ .../src/identity/sys-organization.object.ts | 59 +++++-------------- .../src/pages/sys-organization.page.ts | 5 +- .../src/platform-objects.test.ts | 9 +-- .../src/action-execution-destructive.test.ts | 22 +++---- .../spec/src/identity/membership-reach.ts | 10 ++++ .../spec/src/kernel/public-auth-features.ts | 1 - 9 files changed, 77 insertions(+), 83 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts b/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts index b4eb98b4c74..5bea26ca5bd 100644 --- a/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts +++ b/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts @@ -32,7 +32,12 @@ const LOCALES = [ ['es-ES', esESObjects], ] as const; -/** The 14 (object, action) pairs #7309 converted. */ +/** + * The 13 (object, action) pairs #7309 converted that the platform still + * declares. The fourteenth, `sys_organization.change_slug`, left the platform + * object: its door is the cloud orchestrator's, so the composition that serves + * it declares it. + */ const PAIRS: readonly (readonly [string, string])[] = [ ['sys_user', 'ban_user'], ['sys_user', 'delete_my_account'], @@ -41,7 +46,6 @@ const PAIRS: readonly (readonly [string, string])[] = [ ['sys_two_factor', 'disable_two_factor'], ['sys_two_factor', 'regenerate_backup_codes'], ['sys_account', 'unlink_account'], - ['sys_organization', 'change_slug'], ['sys_team_member', 'remove_team_member'], ['sys_oauth_application', 'enable_oauth_application'], ['sys_oauth_application', 'disable_oauth_application'], diff --git a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts index 1850522f63b..7dc697c421c 100644 --- a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts +++ b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts @@ -54,7 +54,12 @@ const byName = (obj: any, name: string) => { return a; }; -/** The 14 sites #7309 converted: object → action names. */ +/** + * The 13 sites #7309 converted that the platform still declares: object → + * action names. The fourteenth, `sys_organization.change_slug`, left the + * platform object: its door is the cloud orchestrator's, so the composition + * that serves it declares it. + */ const CONVERTED: Record = { sys_user: ['ban_user', 'delete_my_account', 'disable_two_factor', 'generate_backup_codes'], sys_oauth_application: [ @@ -63,7 +68,6 @@ const CONVERTED: Record = { ], sys_two_factor: ['disable_two_factor', 'regenerate_backup_codes'], sys_account: ['unlink_account'], - sys_organization: ['change_slug'], sys_sso_provider: ['delete_sso_provider'], sys_team_member: ['remove_team_member'], }; 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..53e6d01a128 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 @@ -4,27 +4,30 @@ // offer. // // `update_organization` renames too, but it is declared at `list_item` only (a -// record page's header never offers it) and it bundles `slug`, which the cloud -// orchestrator's `change_slug` governs where it exists. So the record page -// needs its own door for `name` and `logo`, one that sends no slug. +// record page's header never offers it) and it bundles `slug`. So the record +// page needs its own door for `name` and `logo`, one that sends no slug. // // What this file pins, from source: // - 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 -// into the overflow (the ordering model is described in +// - the header order: the rename takes an inline slot and leave sorts last +// (the ordering model is described in // `sys-organization-invite-action.test.ts`); -// - controls: `update_organization` and `change_slug` are unchanged. +// - control: `update_organization` is unchanged, and stays the slug door; +// the object declares no other (no `change_slug`: its door is the cloud +// orchestrator's, so the composition that serves it declares it). // // The runtime half, a rename through better-auth that reads the slug back // unchanged, is `organization-rename-door.dogfood.test.ts` in // `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 +100,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); }); @@ -115,18 +118,16 @@ describe('sys_organization: one door per field on the record header', () => { } }); - it('the rename takes an inline header slot and change_slug folds into the overflow', () => { + it('the rename takes an inline header slot and leave sorts last, into the overflow', () => { const order = headerOrder(); - const names = actions().map((a) => a.name); - expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug')); expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0); expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE); - expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE); + expect(order.indexOf('leave_organization')).toBeGreaterThanOrEqual(MAX_VISIBLE); expect(order[order.length - 1]).toBe('leave_organization'); }); }); -describe('sys_organization: the slug doors are unchanged (controls)', () => { +describe('sys_organization: the slug door is unchanged (control)', () => { it('update_organization keeps name / slug / logo, its target and list_item alone', () => { const update = action('update_organization'); expect(update.locations).toEqual(['list_item']); @@ -134,10 +135,9 @@ describe('sys_organization: the slug doors are unchanged (controls)', () => { expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']); }); - it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => { - const change = action('change_slug'); - expect(change.locations).toEqual(['record_header']); - expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug'); - expect(paramKeys(change)).toEqual(['slug']); + it('update_organization is the one action that changes an existing slug', () => { + // `create_organization` collects the new organization's first slug. + expect(actions().filter((a) => paramKeys(a).includes('slug')).map((a) => a.name)) + .toEqual(['create_organization', 'update_organization']); }); }); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 16a3dce6579..304d639ca6c 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, @@ -198,9 +202,9 @@ export const SysOrganization = ObjectSchema.create({ // `invite_user` leading, the header's three inline slots go to the // invite, `set_active_organization` and `rename_organization`, and this // 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. + // page:header split, default `maxVisible` 3). Without it, registration + // order kept it inline as the third button. Ungated by grade: every + // grade may leave. order: 1, mode: 'custom', locations: ['list_item', 'record_header'], @@ -222,15 +226,12 @@ export const SysOrganization = ObjectSchema.create({ // `beforeUpdateOrganization` slug guard both run only on a sent slug. // // One door per field. A record page reaches no `list_item` action, so - // this one is declared at `record_header` too; where the cloud - // orchestrator governs the slug, that page offers this action and - // `change_slug`, and nothing on it sends a slug to better-auth. - // `update_organization` stays the slug door of a composition without the - // orchestrator, because `change_slug`'s target exists only in cloud. - // - // Declared BEFORE `change_slug`: both sort at `order` 0, so declaration - // order breaks the tie, the rename takes the header's third inline slot, - // and `change_slug` folds into the `⋯` overflow. + // this one is declared at `record_header` too, and nothing on that page + // sends a slug to better-auth. Here `update_organization` (list row) is + // the slug door. This object declares no slug action of its own beyond + // it: a composition that governs the slug elsewhere (cloud's + // orchestrator, `change-slug`) declares that action beside the route it + // serves, so no composition is offered a button whose door it lacks. name: 'rename_organization', label: 'Rename Organization', icon: 'pencil', @@ -242,6 +243,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: [ @@ -249,36 +252,6 @@ export const SysOrganization = ObjectSchema.create({ { field: 'logo', defaultFromRow: true }, ], }, - { - // Rename the organization slug (URL prefix). Backed by the cloud - // orchestrator at /api/v1/cloud/organizations/{id}/change-slug, - // which atomically updates sys_organization.slug, rewrites - // platform_subdomain sys_domain rows under the new slug, soft- - // retires the old rows with a redirect window, parks the old - // slug in sys_slug_reservation, and refreshes the registry - // mirror. See cloud `docs/design/sys-domain.md` §6. - name: 'change_slug', - label: 'Change Slug', - icon: 'edit-3', - variant: 'secondary', - mode: 'custom', - locations: ['record_header'], - type: 'api', - target: '/api/v1/cloud/organizations/{id}/change-slug', - method: 'POST', - requiresFeature: 'multiOrgEnabled', - // Confirm question on `description`, not `confirmText`: this action collects - // params, and pairing the two keys opens two dialogs for one decision - // (#7278 ruling 2026-08-10, swept by #7309). The two param-LESS actions - // above (`delete_organization`, `leave_organization`) keep `confirmText` - // — with no param dialog, the confirm IS the only dialog. - description: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?', - successMessage: 'Organization slug changed', - refreshAfter: true, - params: [ - { field: 'slug', required: true, defaultFromRow: true }, - ], - }, ], listViews: { diff --git a/packages/platform-objects/src/pages/sys-organization.page.ts b/packages/platform-objects/src/pages/sys-organization.page.ts index 2f56173ff2c..fa8ff94acb8 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, Rename + * Organization and Leave Organization — 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..893573737d5 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -585,12 +585,13 @@ 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], ['SysUser', SysUser, 'invite_user', `${INVITE} && ${ORG}`], ['SysUser', SysUser, 'create_user', `(${STANDING}) && features.admin == true`], // [#11544] Third mirror of `invite_user` — the Members tab's own copy of diff --git a/packages/runtime/src/action-execution-destructive.test.ts b/packages/runtime/src/action-execution-destructive.test.ts index d6c741e9716..91cc49fd18b 100644 --- a/packages/runtime/src/action-execution-destructive.test.ts +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -135,15 +135,16 @@ describe("actionLooksDestructive: mode:'delete' and variant:'danger' still class }); // --------------------------------------------------------------------------- -// The 6-of-14 flipped identity actions, read off their REAL declarations +// The 6-of-14 flipped identity actions, read off their REAL declarations — +// five now: `sys_organization.change_slug` left the platform object (its door +// is the cloud orchestrator's, so the composition that serves it declares it) // (not hand-rolled fixtures) — the check the ruling explicitly calls for. // --------------------------------------------------------------------------- -describe('the 6 identity actions #7309 flipped now read not-destructive, off their real declarations', () => { +describe('the identity actions #7309 flipped now read not-destructive, off their real declarations', () => { const flipped: Array<[string, any, any]> = [ ['sys_user.generate_backup_codes', SysUser, 'generate_backup_codes'], ['sys_two_factor.regenerate_backup_codes', SysTwoFactor, 'regenerate_backup_codes'], - ['sys_organization.change_slug', SysOrganization, 'change_slug'], ['sys_oauth_application.enable_oauth_application', SysOauthApplication, 'enable_oauth_application'], ['sys_oauth_application.disable_oauth_application', SysOauthApplication, 'disable_oauth_application'], ['sys_oauth_application.rotate_client_secret', SysOauthApplication, 'rotate_client_secret'], @@ -268,14 +269,16 @@ describe('the classification IS live-reachable for a shape gate 1/2/3 all pass }); // --------------------------------------------------------------------------- -// Documents (with real assertions, not narration) why today's 14 identity +// Documents (with real assertions, not narration) why today's 13 identity // actions never reach the classifier via the live MCP path — confirming the // #7828 triage comment's "double-gated dead" plus the third gate found while -// verifying this card (isHeadlessInvokableAction: all 14 are `type: 'api'`). +// verifying this card (isHeadlessInvokableAction: all 13 are `type: 'api'`). +// The fourteenth #7309 site, `sys_organization.change_slug`, left the platform +// object: its door is the cloud orchestrator's. // --------------------------------------------------------------------------- -describe('today, all 14 #7309 identity actions are excluded before actionLooksDestructive ever runs on them', () => { - // The canonical 14 sites #7309 converted — same set pinned by +describe('today, all 13 #7309 identity actions the platform declares are excluded before actionLooksDestructive ever runs on them', () => { + // The 13 sites #7309 converted that the platform still declares — same set pinned by // `platform-objects/src/identity/action-confirm-one-dialog.test.ts`'s // `CONVERTED` map, reproduced here rather than imported so this suite // does not depend on that test file's internals staying exported. @@ -291,13 +294,12 @@ describe('today, all 14 #7309 identity actions are excluded before actionLooksDe ['sys_two_factor.disable_two_factor', 'sys_two_factor', SysTwoFactor, 'disable_two_factor'], ['sys_two_factor.regenerate_backup_codes', 'sys_two_factor', SysTwoFactor, 'regenerate_backup_codes'], ['sys_account.unlink_account', 'sys_account', SysAccount, 'unlink_account'], - ['sys_organization.change_slug', 'sys_organization', SysOrganization, 'change_slug'], ['sys_sso_provider.delete_sso_provider', 'sys_sso_provider', SysSsoProvider, 'delete_sso_provider'], ['sys_team_member.remove_team_member', 'sys_team_member', SysTeamMember, 'remove_team_member'], ]; - it('the fixture reproduces all 14 #7309 sites, no more, no fewer', () => { - expect(allFourteen).toHaveLength(14); + it('the fixture reproduces the 13 #7309 sites the platform declares, no more, no fewer', () => { + expect(allFourteen).toHaveLength(13); }); it.each(allFourteen)('%s is excluded by isSystemObjectName (gate 1: sys_* fail-closed)', (_label, objName) => { 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; diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 05310db88ca..d5102d966b1 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -140,7 +140,6 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization', - 'sys_organization.actions.change_slug', ], notes: 'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' + From 830973d107380e554373ea64d9624b7d7da84461 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:30:30 +0000 Subject: [PATCH 4/7] chore(i18n): regenerate platform-objects bundles without change_slug Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- .../src/apps/translations/en.objects.generated.ts | 5 ----- .../src/apps/translations/es-ES.objects.generated.ts | 5 ----- .../src/apps/translations/ja-JP.objects.generated.ts | 5 ----- .../src/apps/translations/zh-CN.objects.generated.ts | 5 ----- 4 files changed, 20 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 7a0942cb959..278ea9a995b 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -605,11 +605,6 @@ export const enObjects: NonNullable = { label: "Rename Organization", successMessage: "Organization renamed" }, - change_slug: { - label: "Change Slug", - description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?", - successMessage: "Organization slug changed" - }, leave_organization: { label: "Leave Organization", confirmText: "Leave this organization? You will lose access to its environments and members.", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index bc65351b76c..29c91f73438 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -605,11 +605,6 @@ export const esESObjects: NonNullable = { label: "Renombrar organización", successMessage: "Organización renombrada" }, - change_slug: { - label: "Cambiar slug", - description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?", - successMessage: "Slug de la organización cambiado" - }, leave_organization: { label: "Abandonar organización", confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f41fb422c2b..8151bedf229 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -605,11 +605,6 @@ export const jaJPObjects: NonNullable = { label: "組織名を変更", successMessage: "組織名を変更しました" }, - change_slug: { - label: "スラッグを変更", - description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?", - successMessage: "組織のスラッグを変更しました" - }, leave_organization: { label: "組織から退出", confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 2ee763c06f0..2291b998f28 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -605,11 +605,6 @@ export const zhCNObjects: NonNullable = { label: "重命名组织", successMessage: "组织已重命名" }, - change_slug: { - label: "修改标识符", - description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?", - successMessage: "组织标识符已修改" - }, leave_organization: { label: "退出组织", confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。", From 836e199e86357c69d1ddd3c2e3185ebb82f5bd20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:35:11 +0000 Subject: [PATCH 5/7] chore(spec): regenerate the action reference for the two new reach rows Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- content/docs/references/ui/action.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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']` From c59c8ca90e779c85a8c4ef07a2922412afda4ea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:48:42 +0000 Subject: [PATCH 6/7] chore(changeset): spec reach rows and sys_organization admin-action gates Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- .../24384-platform-objects-org-admin-reach.md | 16 ++++++++++++++++ .changeset/24384-spec-org-reach-rows.md | 9 +++++++++ 2 files changed, 25 insertions(+) create mode 100644 .changeset/24384-platform-objects-org-admin-reach.md create mode 100644 .changeset/24384-spec-org-reach-rows.md 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..1b7fb450566 --- /dev/null +++ b/.changeset/24384-platform-objects-org-admin-reach.md @@ -0,0 +1,16 @@ +--- +'@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` and `create_organization` stay open to every grade. + +`sys_organization` no longer declares `change_slug`. Its target, `/api/v1/cloud/organizations/{id}/change-slug`, is served only by the cloud orchestrator, so on every other multi-organization composition the record header offered "Change Slug" and the click answered `404 ENDPOINT_NOT_FOUND`. In this composition the slug door is `update_organization` (better-auth's `organization/update`, list row). + +**Migration, for a host that serves `change-slug`:** declare the action next to the route, as a top-level action with `objectName: 'sys_organization'`, so it is attached to the object and the record page's `page:header` can name it. Copy the action as it was before this release: `name: 'change_slug'`, `type: 'api'`, `method: 'POST'`, `locations: ['record_header']`, `target: '/api/v1/cloud/organizations/{id}/change-slug'`, `requiresFeature: 'multiOrgEnabled'`, its confirm question on `description`, and one `slug` param (`required`, `defaultFromRow`). Ship its translations with it, because the platform's bundles no longer carry `sys_organization._actions.change_slug`. + +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..6b2faa0da45 --- /dev/null +++ b/.changeset/24384-spec-org-reach-rows.md @@ -0,0 +1,9 @@ +--- +'@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. + +`PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug`, because the platform object no longer declares that action (see `@objectstack/platform-objects`). + +Clause-②: yes (widening) From dd87e1cb04be870e97c0859eec88070755cac12b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:51:38 +0000 Subject: [PATCH 7/7] fix(platform-objects): keep change_slug as it was; its shape needs a decision A top-level action bound to sys_organization from another stack is not attached to the served object, so moving change_slug to the composition that serves its door has no working path today. Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb Co-authored-by: Claude --- .../24384-platform-objects-org-admin-reach.md | 6 +- .changeset/24384-spec-org-reach-rows.md | 2 - .../confirm-question-carryover.test.ts | 8 +-- .../apps/translations/en.objects.generated.ts | 5 ++ .../translations/es-ES.objects.generated.ts | 5 ++ .../translations/ja-JP.objects.generated.ts | 5 ++ .../translations/zh-CN.objects.generated.ts | 5 ++ .../action-confirm-one-dialog.test.ts | 8 +-- .../sys-organization-rename-action.test.ts | 30 +++++----- .../src/identity/sys-organization.object.ts | 51 ++++++++++++++--- .../src/pages/sys-organization.page.ts | 6 +- .../src/platform-objects.test.ts | 1 + .../org-record-admin-reach.dogfood.test.ts | 55 ++++++++++--------- .../src/action-execution-destructive.test.ts | 22 ++++---- .../spec/src/kernel/public-auth-features.ts | 1 + 15 files changed, 128 insertions(+), 82 deletions(-) diff --git a/.changeset/24384-platform-objects-org-admin-reach.md b/.changeset/24384-platform-objects-org-admin-reach.md index 1b7fb450566..93c4d4456e9 100644 --- a/.changeset/24384-platform-objects-org-admin-reach.md +++ b/.changeset/24384-platform-objects-org-admin-reach.md @@ -7,10 +7,6 @@ - `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` and `create_organization` stay open to every grade. - -`sys_organization` no longer declares `change_slug`. Its target, `/api/v1/cloud/organizations/{id}/change-slug`, is served only by the cloud orchestrator, so on every other multi-organization composition the record header offered "Change Slug" and the click answered `404 ENDPOINT_NOT_FOUND`. In this composition the slug door is `update_organization` (better-auth's `organization/update`, list row). - -**Migration, for a host that serves `change-slug`:** declare the action next to the route, as a top-level action with `objectName: 'sys_organization'`, so it is attached to the object and the record page's `page:header` can name it. Copy the action as it was before this release: `name: 'change_slug'`, `type: 'api'`, `method: 'POST'`, `locations: ['record_header']`, `target: '/api/v1/cloud/organizations/{id}/change-slug'`, `requiresFeature: 'multiOrgEnabled'`, its confirm question on `description`, and one `slug` param (`required`, `defaultFromRow`). Ship its translations with it, because the platform's bundles no longer carry `sys_organization._actions.change_slug`. +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 index 6b2faa0da45..00ee6374306 100644 --- a/.changeset/24384-spec-org-reach-rows.md +++ b/.changeset/24384-spec-org-reach-rows.md @@ -4,6 +4,4 @@ `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. -`PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug`, because the platform object no longer declares that action (see `@objectstack/platform-objects`). - Clause-②: yes (widening) diff --git a/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts b/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts index 5bea26ca5bd..b4eb98b4c74 100644 --- a/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts +++ b/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts @@ -32,12 +32,7 @@ const LOCALES = [ ['es-ES', esESObjects], ] as const; -/** - * The 13 (object, action) pairs #7309 converted that the platform still - * declares. The fourteenth, `sys_organization.change_slug`, left the platform - * object: its door is the cloud orchestrator's, so the composition that serves - * it declares it. - */ +/** The 14 (object, action) pairs #7309 converted. */ const PAIRS: readonly (readonly [string, string])[] = [ ['sys_user', 'ban_user'], ['sys_user', 'delete_my_account'], @@ -46,6 +41,7 @@ const PAIRS: readonly (readonly [string, string])[] = [ ['sys_two_factor', 'disable_two_factor'], ['sys_two_factor', 'regenerate_backup_codes'], ['sys_account', 'unlink_account'], + ['sys_organization', 'change_slug'], ['sys_team_member', 'remove_team_member'], ['sys_oauth_application', 'enable_oauth_application'], ['sys_oauth_application', 'disable_oauth_application'], diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 278ea9a995b..7a0942cb959 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -605,6 +605,11 @@ export const enObjects: NonNullable = { label: "Rename Organization", successMessage: "Organization renamed" }, + change_slug: { + label: "Change Slug", + description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?", + successMessage: "Organization slug changed" + }, leave_organization: { label: "Leave Organization", confirmText: "Leave this organization? You will lose access to its environments and members.", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 29c91f73438..bc65351b76c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -605,6 +605,11 @@ export const esESObjects: NonNullable = { label: "Renombrar organización", successMessage: "Organización renombrada" }, + change_slug: { + label: "Cambiar slug", + description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?", + successMessage: "Slug de la organización cambiado" + }, leave_organization: { label: "Abandonar organización", confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 8151bedf229..f41fb422c2b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -605,6 +605,11 @@ export const jaJPObjects: NonNullable = { label: "組織名を変更", successMessage: "組織名を変更しました" }, + change_slug: { + label: "スラッグを変更", + description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?", + successMessage: "組織のスラッグを変更しました" + }, leave_organization: { label: "組織から退出", confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 2291b998f28..2ee763c06f0 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -605,6 +605,11 @@ export const zhCNObjects: NonNullable = { label: "重命名组织", successMessage: "组织已重命名" }, + change_slug: { + label: "修改标识符", + description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?", + successMessage: "组织标识符已修改" + }, leave_organization: { label: "退出组织", confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。", diff --git a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts index 7dc697c421c..1850522f63b 100644 --- a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts +++ b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts @@ -54,12 +54,7 @@ const byName = (obj: any, name: string) => { return a; }; -/** - * The 13 sites #7309 converted that the platform still declares: object → - * action names. The fourteenth, `sys_organization.change_slug`, left the - * platform object: its door is the cloud orchestrator's, so the composition - * that serves it declares it. - */ +/** The 14 sites #7309 converted: object → action names. */ const CONVERTED: Record = { sys_user: ['ban_user', 'delete_my_account', 'disable_two_factor', 'generate_backup_codes'], sys_oauth_application: [ @@ -68,6 +63,7 @@ const CONVERTED: Record = { ], sys_two_factor: ['disable_two_factor', 'regenerate_backup_codes'], sys_account: ['unlink_account'], + sys_organization: ['change_slug'], sys_sso_provider: ['delete_sso_provider'], sys_team_member: ['remove_team_member'], }; 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 53e6d01a128..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 @@ -4,8 +4,9 @@ // offer. // // `update_organization` renames too, but it is declared at `list_item` only (a -// record page's header never offers it) and it bundles `slug`. So the record -// page needs its own door for `name` and `logo`, one that sends no slug. +// record page's header never offers it) and it bundles `slug`, which the cloud +// orchestrator's `change_slug` governs where it exists. So the record page +// needs its own door for `name` and `logo`, one that sends no slug. // // What this file pins, from source: // - the new action's declaration: its params are exactly `name` and `logo`, @@ -15,12 +16,10 @@ // `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 leave sorts last -// (the ordering model is described in +// - the header order: the rename takes an inline slot and `change_slug` folds +// into the overflow (the ordering model is described in // `sys-organization-invite-action.test.ts`); -// - control: `update_organization` is unchanged, and stays the slug door; -// the object declares no other (no `change_slug`: its door is the cloud -// orchestrator's, so the composition that serves it declares it). +// - controls: `update_organization` and `change_slug` are unchanged. // // The runtime half, a rename through better-auth that reads the slug back // unchanged, is `organization-rename-door.dogfood.test.ts` in @@ -118,16 +117,18 @@ describe('sys_organization: one door per field on the record header', () => { } }); - it('the rename takes an inline header slot and leave sorts last, into the overflow', () => { + it('the rename takes an inline header slot and change_slug folds into the overflow', () => { const order = headerOrder(); + const names = actions().map((a) => a.name); + expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug')); expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0); expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE); - expect(order.indexOf('leave_organization')).toBeGreaterThanOrEqual(MAX_VISIBLE); + expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE); expect(order[order.length - 1]).toBe('leave_organization'); }); }); -describe('sys_organization: the slug door is unchanged (control)', () => { +describe('sys_organization: the slug doors are unchanged (controls)', () => { it('update_organization keeps name / slug / logo, its target and list_item alone', () => { const update = action('update_organization'); expect(update.locations).toEqual(['list_item']); @@ -135,9 +136,10 @@ describe('sys_organization: the slug door is unchanged (control)', () => { expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']); }); - it('update_organization is the one action that changes an existing slug', () => { - // `create_organization` collects the new organization's first slug. - expect(actions().filter((a) => paramKeys(a).includes('slug')).map((a) => a.name)) - .toEqual(['create_organization', 'update_organization']); + it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => { + const change = action('change_slug'); + expect(change.locations).toEqual(['record_header']); + expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug'); + expect(paramKeys(change)).toEqual(['slug']); }); }); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 304d639ca6c..06e71a1a9a6 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -202,9 +202,9 @@ export const SysOrganization = ObjectSchema.create({ // `invite_user` leading, the header's three inline slots go to the // invite, `set_active_organization` and `rename_organization`, and this // one folds into the `⋯` overflow's destructive band (objectui's - // page:header split, default `maxVisible` 3). Without it, registration - // order kept it inline as the third button. Ungated by grade: every - // grade may leave. + // 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. Ungated by grade: every grade may leave. order: 1, mode: 'custom', locations: ['list_item', 'record_header'], @@ -226,12 +226,15 @@ export const SysOrganization = ObjectSchema.create({ // `beforeUpdateOrganization` slug guard both run only on a sent slug. // // One door per field. A record page reaches no `list_item` action, so - // this one is declared at `record_header` too, and nothing on that page - // sends a slug to better-auth. Here `update_organization` (list row) is - // the slug door. This object declares no slug action of its own beyond - // it: a composition that governs the slug elsewhere (cloud's - // orchestrator, `change-slug`) declares that action beside the route it - // serves, so no composition is offered a button whose door it lacks. + // this one is declared at `record_header` too; where the cloud + // orchestrator governs the slug, that page offers this action and + // `change_slug`, and nothing on it sends a slug to better-auth. + // `update_organization` stays the slug door of a composition without the + // orchestrator, because `change_slug`'s target exists only in cloud. + // + // Declared BEFORE `change_slug`: both sort at `order` 0, so declaration + // order breaks the tie, the rename takes the header's third inline slot, + // and `change_slug` folds into the `⋯` overflow. name: 'rename_organization', label: 'Rename Organization', icon: 'pencil', @@ -252,6 +255,36 @@ export const SysOrganization = ObjectSchema.create({ { field: 'logo', defaultFromRow: true }, ], }, + { + // Rename the organization slug (URL prefix). Backed by the cloud + // orchestrator at /api/v1/cloud/organizations/{id}/change-slug, + // which atomically updates sys_organization.slug, rewrites + // platform_subdomain sys_domain rows under the new slug, soft- + // retires the old rows with a redirect window, parks the old + // slug in sys_slug_reservation, and refreshes the registry + // mirror. See cloud `docs/design/sys-domain.md` §6. + name: 'change_slug', + label: 'Change Slug', + icon: 'edit-3', + variant: 'secondary', + mode: 'custom', + locations: ['record_header'], + type: 'api', + target: '/api/v1/cloud/organizations/{id}/change-slug', + method: 'POST', + requiresFeature: 'multiOrgEnabled', + // Confirm question on `description`, not `confirmText`: this action collects + // params, and pairing the two keys opens two dialogs for one decision + // (#7278 ruling 2026-08-10, swept by #7309). The two param-LESS actions + // above (`delete_organization`, `leave_organization`) keep `confirmText` + // — with no param dialog, the confirm IS the only dialog. + description: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?', + successMessage: 'Organization slug changed', + refreshAfter: true, + params: [ + { field: 'slug', required: true, defaultFromRow: true }, + ], + }, ], listViews: { diff --git a/packages/platform-objects/src/pages/sys-organization.page.ts b/packages/platform-objects/src/pages/sys-organization.page.ts index fa8ff94acb8..45dcaf3fd65 100644 --- a/packages/platform-objects/src/pages/sys-organization.page.ts +++ b/packages/platform-objects/src/pages/sys-organization.page.ts @@ -12,9 +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 - * object's `record_header` actions (Invite User, Set Active, Rename - * Organization and Leave Organization — Edit and Delete are row actions - * only) 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 893573737d5..5ef22b85f72 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -592,6 +592,7 @@ describe('feature-gate lowering matrix (#2874)', () => { ['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], ['SysUser', SysUser, 'invite_user', `${INVITE} && ${ORG}`], ['SysUser', SysUser, 'create_user', `(${STANDING}) && features.admin == true`], // [#11544] Third mirror of `invite_user` — the Members tab's own copy of 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 index 42745bdc6b7..bde6366eeb6 100644 --- a/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts @@ -1,27 +1,29 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The organisation's OWN admin actions follow the caller's membership grade, - * and the organisation record offers no action whose door this composition - * does not serve — measured on a booted multi-organization showcase, on the - * wire, from the same three things the console combines. + * 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 two defects this pins shut + * ## The defect this pins shut * - * 1. `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. - * 2. `change_slug` targeted `/api/v1/cloud/organizations/{id}/change-slug`, a - * route only the cloud orchestrator serves. Here, with multi-org on, the - * record header offered it and the click answered `404 ENDPOINT_NOT_FOUND`. - * The platform object no longer declares it: the composition that serves the - * door declares the action beside it. The open-source slug door is - * `update_organization`, better-auth's `organization/update`. + * `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 * @@ -36,8 +38,8 @@ * * 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 `type: 'api'` action the record serves is called once at its own - * target: none answers the router's `404 ENDPOINT_NOT_FOUND`. + * 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'; @@ -60,6 +62,8 @@ 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, @@ -168,9 +172,9 @@ describe('sys_organization admin actions follow the membership grade, and every } }); - it('every grade is served the same action set, and change_slug is not in it', () => { + 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].sort()); + expect([...served[grade].keys()].sort(), grade).toEqual([...ALL, ...UNDECIDED].sort()); } }); @@ -210,8 +214,9 @@ describe('sys_organization admin actions follow the membership grade, and every expect(await findRows(ql, 'sys_organization', { id: org.doomed.id }, 1)).toEqual([]); }, 60_000); - it('every api action the record serves reaches a door this composition mounts — none answers ENDPOINT_NOT_FOUND', async () => { - const apiActions = [...served.owner.values()].filter((a) => a.type === 'api'); + 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. diff --git a/packages/runtime/src/action-execution-destructive.test.ts b/packages/runtime/src/action-execution-destructive.test.ts index 91cc49fd18b..d6c741e9716 100644 --- a/packages/runtime/src/action-execution-destructive.test.ts +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -135,16 +135,15 @@ describe("actionLooksDestructive: mode:'delete' and variant:'danger' still class }); // --------------------------------------------------------------------------- -// The 6-of-14 flipped identity actions, read off their REAL declarations — -// five now: `sys_organization.change_slug` left the platform object (its door -// is the cloud orchestrator's, so the composition that serves it declares it) +// The 6-of-14 flipped identity actions, read off their REAL declarations // (not hand-rolled fixtures) — the check the ruling explicitly calls for. // --------------------------------------------------------------------------- -describe('the identity actions #7309 flipped now read not-destructive, off their real declarations', () => { +describe('the 6 identity actions #7309 flipped now read not-destructive, off their real declarations', () => { const flipped: Array<[string, any, any]> = [ ['sys_user.generate_backup_codes', SysUser, 'generate_backup_codes'], ['sys_two_factor.regenerate_backup_codes', SysTwoFactor, 'regenerate_backup_codes'], + ['sys_organization.change_slug', SysOrganization, 'change_slug'], ['sys_oauth_application.enable_oauth_application', SysOauthApplication, 'enable_oauth_application'], ['sys_oauth_application.disable_oauth_application', SysOauthApplication, 'disable_oauth_application'], ['sys_oauth_application.rotate_client_secret', SysOauthApplication, 'rotate_client_secret'], @@ -269,16 +268,14 @@ describe('the classification IS live-reachable for a shape gate 1/2/3 all pass }); // --------------------------------------------------------------------------- -// Documents (with real assertions, not narration) why today's 13 identity +// Documents (with real assertions, not narration) why today's 14 identity // actions never reach the classifier via the live MCP path — confirming the // #7828 triage comment's "double-gated dead" plus the third gate found while -// verifying this card (isHeadlessInvokableAction: all 13 are `type: 'api'`). -// The fourteenth #7309 site, `sys_organization.change_slug`, left the platform -// object: its door is the cloud orchestrator's. +// verifying this card (isHeadlessInvokableAction: all 14 are `type: 'api'`). // --------------------------------------------------------------------------- -describe('today, all 13 #7309 identity actions the platform declares are excluded before actionLooksDestructive ever runs on them', () => { - // The 13 sites #7309 converted that the platform still declares — same set pinned by +describe('today, all 14 #7309 identity actions are excluded before actionLooksDestructive ever runs on them', () => { + // The canonical 14 sites #7309 converted — same set pinned by // `platform-objects/src/identity/action-confirm-one-dialog.test.ts`'s // `CONVERTED` map, reproduced here rather than imported so this suite // does not depend on that test file's internals staying exported. @@ -294,12 +291,13 @@ describe('today, all 13 #7309 identity actions the platform declares are exclude ['sys_two_factor.disable_two_factor', 'sys_two_factor', SysTwoFactor, 'disable_two_factor'], ['sys_two_factor.regenerate_backup_codes', 'sys_two_factor', SysTwoFactor, 'regenerate_backup_codes'], ['sys_account.unlink_account', 'sys_account', SysAccount, 'unlink_account'], + ['sys_organization.change_slug', 'sys_organization', SysOrganization, 'change_slug'], ['sys_sso_provider.delete_sso_provider', 'sys_sso_provider', SysSsoProvider, 'delete_sso_provider'], ['sys_team_member.remove_team_member', 'sys_team_member', SysTeamMember, 'remove_team_member'], ]; - it('the fixture reproduces the 13 #7309 sites the platform declares, no more, no fewer', () => { - expect(allFourteen).toHaveLength(13); + it('the fixture reproduces all 14 #7309 sites, no more, no fewer', () => { + expect(allFourteen).toHaveLength(14); }); it.each(allFourteen)('%s is excluded by isSystemObjectName (gate 1: sys_* fail-closed)', (_label, objName) => { diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index d5102d966b1..05310db88ca 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -140,6 +140,7 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization', + 'sys_organization.actions.change_slug', ], notes: 'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' +