diff --git a/.changeset/24318-platform-objects-change-slug-to-cloud.md b/.changeset/24318-platform-objects-change-slug-to-cloud.md new file mode 100644 index 00000000000..44c60b779b4 --- /dev/null +++ b/.changeset/24318-platform-objects-change-slug-to-cloud.md @@ -0,0 +1,21 @@ +--- +'@objectstack/platform-objects': minor +--- + +fix(platform-objects)!: `sys_organization` no longer declares `change_slug`, whose door only the cloud orchestrator mounts (#24318) + +Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing) + +`sys_organization` declared a `change_slug` action ("Change Slug") at `record_header`, gated on `multiOrgEnabled`, whose target, `/api/v1/cloud/organizations/{id}/change-slug`, no package in this repository serves. So on every open-source composition with multi-organization on, the organization record page offered Change Slug in its `⋯` overflow, and the click answered `404 ENDPOINT_NOT_FOUND`. Measured on a booted multi-organization showcase. + +The action now belongs to the composition that serves its route. That composition declares `change_slug` as a top-level action bound by `objectName: 'sys_organization'`, and the registry attaches such an action to the served object, whichever package registers first. + +**BREAKING — what moves for consumers.** A declared action leaves a published platform object, shipped as `minor` under the launch-window convention for breaking changes (pre mode was exited at `c46b5f14cf`, so the launch-window guard is armed and a breaking change ships as `minor`). + +- FROM: `GET /api/v1/meta/object/sys_organization` served `change_slug` on every composition, and the four generated locale bundles carried its `label`, `description` and `successMessage`. +- TO: the object serves it only where a composition binds it. The open-source record header offers Invite User, Set Active and Rename Organization inline, and Leave Organization in the overflow; each of them is a better-auth door this composition mounts. +- Fix: cloud declares its own `change_slug`, the same declaration bound by `objectName: 'sys_organization'`, with its own translations. An open-source composition changes an organization's slug through `update_organization` (better-auth's `organization/update`, list row), as before. + +**Unchanged.** `rename_organization` (name and logo, no slug) and `update_organization` (name, slug and logo, list row only) keep their declarations, gates and order. No stored row moves. + + diff --git a/.changeset/24318-spec-change-slug-gated-input.md b/.changeset/24318-spec-change-slug-gated-input.md new file mode 100644 index 00000000000..ef23fd429b5 --- /dev/null +++ b/.changeset/24318-spec-change-slug-gated-input.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug` (#24318) + +Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing) + +`gatedInputs` is the census of platform-object inputs gated on a public auth feature, which the platform objects' completeness guard holds equal to their declarations in both directions. `sys_organization` no longer declares `change_slug` (its door is the cloud orchestrator's, and the composition that serves it declares the action), so its row leaves the census. + +**BREAKING — what moves for consumers.** One string leaves a published constant, shipped as `minor` under the launch-window convention for breaking changes. + +- FROM: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` listed seven `sys_organization` actions, `'sys_organization.actions.change_slug'` among them. +- TO: it lists six: `create_organization`, `update_organization`, `rename_organization`, `delete_organization`, `set_active_organization` and `leave_organization`. +- Fix: none for an action declared outside the platform objects. The census is not a gate on other packages: a top-level action bound to `sys_organization` that declares `requiresFeature: 'multiOrgEnabled'` is lowered to the same `features.multiOrgEnabled != false` predicate and served with it, measured on a booted composition. + +**Unchanged.** The `multiOrgEnabled` key, its surface (`crud`) and its semantics (`default-on`); every other feature's row. The constant is declared `as const`, so its literal type loses the one tuple member too. + + 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..a1fdba5f27c 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 @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #7309 — translation-carryover guard for the 14 confirm questions moved from - * `confirmText` to `description`. + * #7309 — translation-carryover guard for the confirm questions moved from + * `confirmText` to `description` (14 then, 13 the platform still declares). * * The wording did not change; the KEY did. `os i18n extract` treats a renamed * key as a brand-new gap, and this repo's `i18n:extract` script runs with @@ -32,7 +32,12 @@ const LOCALES = [ ['es-ES', esESObjects], ] as const; -/** The 14 (object, action) pairs #7309 converted. */ +/** + * The (object, action) pairs #7309 converted that this package still + * declares: 13 of its 14. `sys_organization.change_slug` left the platform + * object (its door is the cloud orchestrator's, and the composition that + * mounts it declares it), so its strings left these bundles with 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'], @@ -78,6 +82,15 @@ describe('#7309 — the moved confirm questions stay translated', () => { } }); + it('the pinned set is 13 pairs, and no locale still carries sys_organization.change_slug', () => { + expect(PAIRS).toHaveLength(13); + for (const [locale, bundle] of [['en', enObjects] as const, ...LOCALES]) { + expect(node(bundle, 'sys_organization', 'change_slug'), `${locale} sys_organization.change_slug`).toBeUndefined(); + // The control: the sibling actions of the same object are still there. + expect(node(bundle, 'sys_organization', 'rename_organization'), `${locale} sys_organization.rename_organization`).toBeDefined(); + } + }); + it('no locale left the retired `confirmText` behind on these actions', () => { for (const [locale, bundle] of LOCALES) { for (const [obj, act] of PAIRS) { 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 1d6c8950c6f..c3fea6d5892 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 d9088440594..38f92251b46 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 c2c3a1a1857..b19afd78a6c 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 a2ceffed877..3d3a1139873 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: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。", 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..bd70f3c4316 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 sites #7309 converted that this package still declares: object → action + * names. #7309 converted 14; the fifteenth row it carried, + * `sys_organization.change_slug`, left the platform object, because its door + * is the cloud orchestrator's and the composition that mounts 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 e2202efa89e..5537261e045 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 @@ -5,8 +5,9 @@ // // `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. +// orchestrator's slug action governs where it exists — an action the +// composition serving that route declares itself, never this object. 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`, @@ -16,10 +17,12 @@ // `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 folds into +// the overflow (the ordering model is described in // `sys-organization-invite-action.test.ts`); -// - controls: `update_organization` and `change_slug` are unchanged. +// - every door this object declares is better-auth's: no action targets the +// cloud orchestrator, whose route no package in this repository serves; +// - control: `update_organization`, the open-source slug door, is unchanged. // // The runtime half, a rename through better-auth that reads the slug back // unchanged, is `organization-rename-door.dogfood.test.ts` in @@ -117,29 +120,35 @@ 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_organization 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).toEqual(['invite_user', 'set_active_organization', 'rename_organization', 'leave_organization']); expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE); - expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE); - expect(order[order.length - 1]).toBe('leave_organization'); + expect(order.indexOf('leave_organization')).toBeGreaterThanOrEqual(MAX_VISIBLE); }); }); -describe('sys_organization: the slug doors are unchanged (controls)', () => { +describe('sys_organization: every door it declares is one an open-source composition mounts', () => { + it("every api action targets better-auth's organization routes, and none the cloud orchestrator", () => { + const api = actions().filter((a) => a.type === 'api'); + expect(api.map((a) => a.name).sort()).toEqual([ + 'create_organization', 'delete_organization', 'invite_user', 'leave_organization', + 'rename_organization', 'set_active_organization', 'update_organization', + ]); + for (const a of api) { + expect(a.target, a.name).toMatch(/^\/api\/v1\/auth\/organization\/[a-z-]+$/); + } + // The slug action the cloud orchestrator serves is declared by the + // composition that mounts its route, bound here by `objectName`. + expect(actions().map((a) => a.name)).not.toContain('change_slug'); + }); +}); + +describe('sys_organization: the open-source 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']); expect(update.target).toBe(UPDATE_TARGET); 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']); - }); }); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 06e71a1a9a6..0f0938ef1d2 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), 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. + // 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'], @@ -226,15 +226,13 @@ 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. `update_organization` is the slug door of + // a composition without the cloud orchestrator. Where the orchestrator + // governs the slug, the composition that serves its route declares the + // slug action itself, bound to this object by `objectName` and attached + // to it as served; this object declares no action whose door only one + // composition mounts. name: 'rename_organization', label: 'Rename Organization', icon: 'pencil', @@ -255,36 +253,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 45dcaf3fd65..2998b6ec9e1 100644 --- a/packages/platform-objects/src/pages/sys-organization.page.ts +++ b/packages/platform-objects/src/pages/sys-organization.page.ts @@ -13,8 +13,8 @@ import type { Page } from '@objectstack/spec/ui'; * 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 and Rename - * Organization inline, Change Slug and Leave Organization in the overflow — - * Edit and Delete are row actions only) are preserved. + * Organization inline, 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 5ef22b85f72..7c4e59b33c5 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -592,7 +592,6 @@ 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 @@ -650,6 +649,19 @@ describe('feature-gate lowering matrix (#2874)', () => { expect((action?.visible as { source?: string })?.source).toBe(expected); }); + // The rows above are the whole multi-org surface of sys_organization: an + // action gated on `multiOrgEnabled` without a row here is one this matrix + // never pinned. `change_slug` is not among them — its door is the cloud + // orchestrator's, and the composition that mounts it declares the action. + it('the SysOrganization rows are every action it gates on multiOrgEnabled, and change_slug is none of them', () => { + const gated = (SysOrganization.actions ?? []) + .filter((a) => ((a.visible as { source?: string } | undefined)?.source ?? '').includes('features.multiOrgEnabled')) + .map((a) => a.name); + const pinned = rows.filter(([exported]) => exported === 'SysOrganization').map(([, , name]) => name); + expect([...gated].sort()).toEqual([...pinned].sort()); + expect(gated).not.toContain('change_slug'); + }); + it('SysUser.create_user#phoneNumber param lowers to the previous hand-written predicate', () => { const create = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); const phone = (create?.params ?? []).find((p) => (p as { name?: string }).name === 'phoneNumber'); diff --git a/packages/qa/dogfood/test/org-change-slug-bound-action.dogfood.test.ts b/packages/qa/dogfood/test/org-change-slug-bound-action.dogfood.test.ts new file mode 100644 index 00000000000..ae8876d9e38 --- /dev/null +++ b/packages/qa/dogfood/test/org-change-slug-bound-action.dogfood.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The cloud orchestrator's slug action reaches the organization record page + * from the composition that serves its route — measured on a booted + * multi-organization showcase, on the wire. + * + * ## Why the platform object no longer declares it + * + * `change_slug` targets `/api/v1/cloud/organizations/{id}/change-slug`, a + * route only the cloud orchestrator serves. Declared on `sys_organization`, it + * was offered on every multi-organization composition, and outside cloud the + * click answered `404 ENDPOINT_NOT_FOUND`. The composition that mounts the + * route declares the action itself, as a top-level action bound by + * `objectName: 'sys_organization'`; the registry attaches such an action to + * the served object, whichever package registers first. + * + * ## What this pins + * + * A package shaped like cloud's — its own manifest, one top-level action with + * the declaration the platform object carried — is mounted beside the + * showcase. Then: + * - `GET /meta/object/sys_organization` serves `change_slug` once, with the + * orchestrator's target, method, `record_header` location and one `slug` + * param, and the `multiOrgEnabled` gate lowered exactly as the platform's + * own `requiresFeature` actions are; + * - the gate admits it with multi-org on and hides it with multi-org off, + * under `@objectstack/formula`'s `celEngine`, the console's engine; + * - in the header's stable `order` sort it follows the rename and precedes + * leave, so it sits in the `⋯` overflow, where the platform copy sat; + * - the boot names no collision for it: the platform object declares no + * `change_slug` of its own. + * + * The open-source half — the record page offers no action whose door this + * composition does not mount — is `org-record-admin-reach.dogfood.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { AppPlugin } from '@objectstack/runtime'; +import { defineStack } from '@objectstack/spec'; +import { featureGatePredicate } from '@objectstack/spec/kernel'; +import { celEngine } from '@objectstack/formula'; +import { bootShowcase } from './showcase-boot.js'; + +const TARGET = '/api/v1/cloud/organizations/{id}/change-slug'; +const BINDER = 'com.fixture.cloud-orchestrator-shaped'; + +/** The declaration cloud's composition carries: the platform object's former copy, bound by `objectName`. */ +const cloudShaped = defineStack({ + manifest: { id: BINDER, name: 'cloud_orchestrator_shaped', version: '0.0.1', type: 'app' }, + actions: [ + { + name: 'change_slug', + objectName: 'sys_organization', + label: 'Change Slug', + icon: 'edit-3', + variant: 'secondary', + mode: 'custom', + locations: ['record_header'], + type: 'api', + target: TARGET, + method: 'POST', + requiresFeature: 'multiOrgEnabled', + 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 }], + }, + ], +} as never); + +type ServedAction = { + name: string; + type?: string; + target?: string; + method?: string; + order?: number; + locations?: string[]; + visible?: { dialect?: string; source?: string }; + params?: Array>; +}; + +describe('a cloud-shaped composition binds change_slug to sys_organization, and it is served gated', () => { + let stack: VerifyStack; + let actions: ServedAction[] = []; + let features: Record = {}; + const bootLines: string[] = []; + + beforeAll(async () => { + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + bootLines.push(...String(chunk).replace(/\u001b\[[0-9;]*m/g, '').split('\n')); + return true; + }) as never); + try { + stack = await bootShowcase({ multiTenant: 'posture-only', extraPlugins: [new AppPlugin(cloudShaped as never)] }); + } finally { + spy.mockRestore(); + } + const token = await stack.signUp('org-change-slug.binder@verify.test', 'ChangeSlug!Pass123', 'Change slug binder'); + const config = await stack.apiAs(token, 'GET', '/auth/config'); + expect(config.status).toBe(200); + features = ((await config.json()) as { data: { features: Record } }).data.features; + const meta = await stack.apiAs(token, 'GET', '/meta/object/sys_organization'); + expect(meta.status).toBe(200); + actions = ((await meta.json()) as { item?: { actions?: ServedAction[] } }).item?.actions ?? []; + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('serves change_slug once, with the orchestrator target, its location and its one slug param', () => { + const served = actions.filter((a) => a.name === 'change_slug'); + expect(served).toHaveLength(1); + expect(served[0]).toMatchObject({ type: 'api', target: TARGET, method: 'POST', locations: ['record_header'] }); + expect(served[0].params).toEqual([{ field: 'slug', required: true, defaultFromRow: true }]); + }); + + it('carries the multiOrgEnabled gate, lowered as the platform lowers its own', () => { + const changeSlug = actions.find((a) => a.name === 'change_slug'); + const leave = actions.find((a) => a.name === 'leave_organization'); + expect(changeSlug?.visible).toEqual({ dialect: 'cel', source: featureGatePredicate('multiOrgEnabled') }); + // The platform's own multi-org action, gated by nothing else, serves the same predicate. + expect(changeSlug?.visible).toEqual(leave?.visible); + }); + + it('the gate admits it with multi-org on and hides it with multi-org off', () => { + expect(features.multiOrgEnabled).toBe(true); + const visible = actions.find((a) => a.name === 'change_slug')?.visible; + const evaluate = (multiOrgEnabled: boolean) => { + const result = celEngine.evaluate(visible as never, { record: {}, extra: { features: { ...features, multiOrgEnabled } } }); + if (!result.ok) throw new Error(`change_slug's gate faulted: ${JSON.stringify(result)}`); + return result.value; + }; + expect(evaluate(true)).toBe(true); + expect(evaluate(false)).toBe(false); + }); + + it('sorts after the rename and before leave in the record header, so it sits in the overflow', () => { + const header = actions + .filter((a) => (a.locations ?? []).includes('record_header')) + .map((a, i) => ({ a, i })) + .sort((x, y) => (x.a.order ?? 0) - (y.a.order ?? 0) || x.i - y.i) + .map(({ a }) => a.name); + expect(header).toEqual(['invite_user', 'set_active_organization', 'rename_organization', 'change_slug', 'leave_organization']); + }); + + it('the boot names no collision for it: the platform object declares no change_slug of its own', () => { + // Positive control: the capture saw the boot's own kernel log (the ObjectQL + // plugin's lines), so an empty match below is a reading, not a dead spy. + expect(bootLines.some((line) => line.includes('[ObjectQLPlugin]'))).toBe(true); + expect(bootLines.filter((line) => line.includes("'change_slug'") && / (WARN|ERROR) /.test(line))).toEqual([]); + }); +}); 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 bde6366eeb6..1573fc8c981 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 @@ -16,14 +16,14 @@ * (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` + * ## No action without its door * - * 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. + * The object used to declare `change_slug` too, whose target + * (`/api/v1/cloud/organizations/{id}/change-slug`) only the cloud orchestrator + * serves: on this composition it answered `404 ENDPOINT_NOT_FOUND`. The + * composition that mounts that route now declares the action itself, bound by + * `objectName` (`org-change-slug-bound-action.dogfood.test.ts`), so the record + * serves only the actions below, and the door probe calls every one of them. * * ## What this measures * @@ -38,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 other `type: 'api'` action the record serves is called once at its - * own target: none answers the router's `404 ENDPOINT_NOT_FOUND`. + * 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'; @@ -62,8 +62,6 @@ 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, @@ -174,7 +172,7 @@ describe('sys_organization admin actions follow the membership grade, and every 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()); + expect([...served[grade].keys()].sort(), grade).toEqual([...ALL].sort()); } }); @@ -214,9 +212,8 @@ 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 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)); + 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'); // 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 d6c741e9716..89f2666b311 100644 --- a/packages/runtime/src/action-execution-destructive.test.ts +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -29,7 +29,7 @@ * 3. `actionAiExposureError` — excluded unless the author set * `ai.exposed: true` * - * All 14 of #7309's identity actions live on `sys_*` objects, are + * All 14 of #7309's identity actions lived on `sys_*` objects (13 still do), are * `type: 'api'` (not `script`/`flow`, so gate 2 excludes them independently * of gate 1), and none declares `ai.exposed`. So today's real declarations * are excluded on THREE independent grounds before `actionLooksDestructive` @@ -136,14 +136,16 @@ describe("actionLooksDestructive: mode:'delete' and variant:'danger' still class // --------------------------------------------------------------------------- // The 6-of-14 flipped identity actions, read off their REAL declarations -// (not hand-rolled fixtures) — the check the ruling explicitly calls for. +// (not hand-rolled fixtures) — the check the ruling explicitly calls for. Five +// are still platform declarations; the sixth, `sys_organization.change_slug`, +// left the platform object (its door is the cloud orchestrator's, and the +// composition that mounts it declares it), so it is pinned absent instead. // --------------------------------------------------------------------------- -describe('the 6 identity actions #7309 flipped now read not-destructive, off their real declarations', () => { +describe('the 5 identity actions #7309 flipped that the platform still declares 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'], @@ -158,6 +160,11 @@ describe('the 6 identity actions #7309 flipped now read not-destructive, off the expect(action.variant).not.toBe('danger'); expect(actionLooksDestructive(deps, action)).toBe(false); }); + + it('sys_organization no longer declares change_slug, the sixth', () => { + expect(flipped).toHaveLength(5); + expect((SysOrganization.actions ?? []).map((a: any) => a.name)).not.toContain('change_slug'); + }); }); // --------------------------------------------------------------------------- @@ -268,18 +275,20 @@ 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'`). +// #7309 converted 14; `sys_organization.change_slug` left the platform object. // --------------------------------------------------------------------------- -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 still declares are excluded before actionLooksDestructive ever runs on them', () => { + // The 13 of the 14 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. - const allFourteen: Array<[string, string, any, string]> = [ + const allThirteen: Array<[string, string, any, string]> = [ ['sys_user.ban_user', 'sys_user', SysUser, 'ban_user'], ['sys_user.delete_my_account', 'sys_user', SysUser, 'delete_my_account'], ['sys_user.disable_two_factor', 'sys_user', SysUser, 'disable_two_factor'], @@ -291,26 +300,25 @@ 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 still declares, no more, no fewer', () => { + expect(allThirteen).toHaveLength(13); }); - it.each(allFourteen)('%s is excluded by isSystemObjectName (gate 1: sys_* fail-closed)', (_label, objName) => { + it.each(allThirteen)('%s is excluded by isSystemObjectName (gate 1: sys_* fail-closed)', (_label, objName) => { expect(isSystemObjectName(objName)).toBe(true); }); - it.each(allFourteen)('%s is excluded by isHeadlessInvokableAction (gate 2: type is not script/flow)', (_label, _objName, obj, name) => { + it.each(allThirteen)('%s is excluded by isHeadlessInvokableAction (gate 2: type is not script/flow)', (_label, _objName, obj, name) => { const action = actionByName(obj, name); expect(action.type).toBe('api'); expect(isHeadlessInvokableAction(deps, action, true)).toBe(false); }); - it.each(allFourteen)('%s is excluded by actionAiExposureError (gate 3: ai.exposed not set)', (_label, _objName, obj, name) => { + it.each(allThirteen)('%s is excluded by actionAiExposureError (gate 3: ai.exposed not set)', (_label, _objName, obj, name) => { const action = actionByName(obj, name); expect(action.ai?.exposed).not.toBe(true); // Matches the real call site (domains/mcp.ts listActions): 2-arg, no 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 ' +