Skip to content

Commit 29d0ed3

Browse files
fix(platform-objects,spec)!: sys_organization no longer declares change_slug, whose door only cloud mounts (#24657)
Fixes #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) Dev session `session_015mj2S1LnzLNur4rJeZuMbN`, dispatched by the `domain:engine` seat 1 (`session_01JtSQQ3EWsbNPzyYMug1tXy`) under claim 6275837047. Shape B of the release 6255698861: the composition that serves `/api/v1/cloud/organizations/{id}/change-slug` declares `change_slug` itself, through the top-level action bound by `objectName` that PR #24458 attaches, and `sys_organization` stops declaring it. ## What changes - **`packages/platform-objects/src/identity/sys-organization.object.ts`**: `change_slug` is removed with its comments. The comments on `leave_organization` and `rename_organization` now say what is true: the header carries Invite User, Set Active and Rename Organization inline and Leave Organization in the overflow, and the slug action is declared by the composition that serves its route. The action order is unchanged. `defaultRowScope` (PR #24623's region) is not touched. - **The four generated locales** lose `sys_organization._actions.change_slug`, regenerated with `node scripts/check-i18n-bundles.mjs --write` (20 lines removed, nothing else moved). - **`packages/spec/src/kernel/public-auth-features.ts`**: the `multiOrgEnabled.gatedInputs` row `sys_organization.actions.change_slug` is removed. It is the platform objects' own census: `feature-gate-guard.test.ts` walks only the platform-objects modules, and its forward direction reds when a booked path names an action that no longer exists. It is not a gate on other packages (measured below), so cloud does not need it. - **`packages/platform-objects/src/pages/sys-organization.page.ts`**: the docblock that listed Change Slug in the header overflow (comment only). Outside the claim's file surface; the removal made it false. - **Pins and changesets** below. ## Measured on a booted multi-organization showcase (M) The harness: `bootShowcase({ multiTenant: 'posture-only' })`, `/auth/config` serving `multiOrgEnabled: true`. The cloud-shaped composition adds one `AppPlugin` whose stack declares a top-level `change_slug` with `objectName: 'sys_organization'` and the platform's former declaration byte for byte (target, `method: 'POST'`, `record_header`, the one `slug` param, `requiresFeature: 'multiOrgEnabled'`, description, success message). Before, on `origin/main` `1e8b6284c0`: | composition | `change_slug` served | its `visible` | `POST /api/v1/cloud/organizations/org_x/change-slug` | boot report | |---|---|---|---|---| | showcase | once (the platform's) | `features.multiOrgEnabled != false` | `404` `ENDPOINT_NOT_FOUND` | none | | + cloud-shaped, identical declaration | once (the platform's) | same | `404` | none (`sameActionDeclaration` reads one declaration) | | + cloud-shaped, label differs | once, label `Change Slug` (the platform's) | same | `404` | one `WARN`: declared twice, `com.objectstack.plugin-auth` served, `com.measure.cloud-shaped` not listed | After, on this branch: | composition | `change_slug` served | its `visible` | header position | |---|---|---|---| | showcase | **absent**; the record serves create, update, delete, set active, invite, leave and rename | n/a | n/a | | + cloud-shaped, identical declaration | once, attached after the object's own actions | `features.multiOrgEnabled != false`, the same lowering as the platform's own `requiresFeature` actions | after rename, before leave: the `⋯` overflow, where the platform copy sat | | + cloud-shaped, label differs | once, the binder's (`Change Slug (cloud)`) | same | same | Nothing refuses a non-platform action that declares `requiresFeature: 'multiOrgEnabled'`: the boot succeeds, the sugar is lowered at parse like any action's, and no `gatedInputs` row is consulted at runtime. ## What the cloud repository needs (for the `repo:cloud#2` seat's pointer) 1. One top-level action in cloud's composition, bound by `objectName`: ```ts { name: 'change_slug', objectName: 'sys_organization', 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', 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 }], } ``` 2. Its translations, the keys this PR removes from platform-objects' bundles (`objects.sys_organization._actions.change_slug`): | locale | label | description | successMessage | |---|---|---|---| | en | Change Slug | Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue? | Organization slug changed | | zh-CN | 修改标识符 | 重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗? | 组织标识符已修改 | | ja-JP | スラッグを変更 | スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか? | 組織のスラッグを変更しました | | es-ES | Cambiar slug | Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar? | Slug de la organización cambiado | Whether a translation bundle in cloud's package reaches another package's object's `_actions` was not measured here; the cloud seat owns that check. 3. Land it before, or with, cloud's pin bump past this PR. Until then cloud's record page has no Change Slug. If cloud declares it while this platform copy is still in its pin, the identical declaration is served once and nothing is reported; a declaration that differs is reported at boot and the platform's copy is the one served. ## Pins Pin sweep: `git grep -n "change_slug\|change-slug"` over the tree. Every hit outside CHANGELOGs and landed `.changeset/pre` entries moved: - `sys-organization-rename-action.test.ts`: the header order is now asserted exactly (invite, set active, rename inline; leave in the overflow). The `change_slug` control is replaced by "every api action targets better-auth's `organization/` routes, none the cloud orchestrator, and no `change_slug` is declared". - `platform-objects.test.ts`: the `change_slug` lowering row is removed, and a new case holds the matrix's `SysOrganization` rows equal to every action the object gates on `multiOrgEnabled`. - `action-confirm-one-dialog.test.ts`: the `sys_organization` entry leaves the converted map (13 of #7309's 14 remain platform declarations). - `confirm-question-carryover.test.ts`: the pair leaves the set, and a new case asserts 13 pairs and that no locale bundle (en included) carries `sys_organization.change_slug`, with `rename_organization` as the control. - `runtime/action-execution-destructive.test.ts`: the flipped set is 5 and the full set is 13, and a new case asserts `SysOrganization` declares no `change_slug`. - `qa/dogfood/org-record-admin-reach.dogfood.test.ts`: the `UNDECIDED` hold-out is gone. The served set is asserted equal to the seven graded actions, and the door probe calls **every** api action the record serves, so the record offers no action whose target answers `404` (the first new pin of the claim). - New: `qa/dogfood/org-change-slug-bound-action.dogfood.test.ts` boots the showcase with the cloud-shaped package and asserts `change_slug` is served once with the orchestrator target, `POST`, `record_header` and its one `slug` param; its `visible` equals `featureGatePredicate('multiOrgEnabled')` and `leave_organization`'s; under `celEngine` it is admitted with multi-org on and hidden with it off; it sorts after rename and before leave; and the boot names no collision for it (the second new pin). ## Verification Tests (head `91dc52d3e`, after merging `origin/main` `7c7741dcb`, unless noted): - `pnpm --filter @objectstack/platform-objects test`: 78 files, 1191 tests passed. - `pnpm --filter @objectstack/spec test`: 701 files, 21313 passed, 1 todo (pre-merge head `048de3158`). - `pnpm --filter @objectstack/runtime test`: 425 files, 6668 passed, 19 skipped (pre-merge head `048de3158`). - dogfood: `org-record-admin-reach` and `org-change-slug-bound-action`: 2 files, 12 tests passed. - `pnpm --filter @objectstack/core test:repo`: 6 files, 82 passed. - typecheck, exit 0 on each: `@objectstack/platform-objects`, `@objectstack/spec`, `@objectstack/runtime`, `@objectstack/dogfood` (after the merge). - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date (after the merge and a spec rebuild). `node scripts/check-i18n-bundles.mjs`: exit 0. Ablation (`scripts/ablation-replace.mjs`, trap-restored; dist proved by `scripts/ablation-dist-preflight.mjs`): the platform copy put back into `sys-organization.object.ts` and platform-objects rebuilt (marker present in 6 built files): - dogfood `org-record-admin-reach`: **red**, 2 cases ("every grade is served the same action set", and "every api action ... none answers ENDPOINT_NOT_FOUND"). - platform-objects: **red**, 4 cases (the rename file's header order and api-target cases, the matrix completeness case, and `feature-gate-guard`'s reverse direction, which reds because the row is gone). - runtime: **red**, 1 case ("sys_organization no longer declares change_slug, the sixth"). - `org-change-slug-bound-action` stays green under the ablation, as expected: with an identical platform copy present the served declaration is the same bytes. Restored: `git diff HEAD` empty and the blob hash equals HEAD's; rebuilt, marker absent from all 66 built files; the dogfood pair green again (12/12). Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 88 families; all 88 ran on `91dc52d3e` with exit 0, and `--ran` reconciled "88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". `check:dual-build-cjs-loads` first exited 3 (eight packages had no `dist/`); they were built and it passed. The last commit, `44da7fe8d`, touches only the two changesets (the claim's `Clause-②` line carried verbatim), and the nine changeset families were re-run on it, all exit 0. ## Acceptance notes - `sys-organization.page.ts` is outside the claim's `File surface:` list; its docblock named Change Slug in the header overflow, which this change made false. - The new dogfood file is not the ablation target for the platform copy; `org-record-admin-reach` is, and it reds. - Cloud-side items not measured here: whether cloud's translation bundle reaches `sys_organization._actions` of another package's object; cloud's own boot with the declaration. --- _Generated by [Claude Code](https://claude.ai/code/session_015mj2S1LnzLNur4rJeZuMbN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92ce3af commit 29d0ed3

16 files changed

Lines changed: 306 additions & 121 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
fix(platform-objects)!: `sys_organization` no longer declares `change_slug`, whose door only the cloud orchestrator mounts (#24318)
6+
7+
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)
8+
9+
`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.
10+
11+
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.
12+
13+
**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`).
14+
15+
- 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`.
16+
- 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.
17+
- 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.
18+
19+
**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.
20+
21+
<!-- adr-0087: not-required (no-migration-prescription) a platform object stops declaring one action whose door exists only in another composition: no authorable spec key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. The repair is the serving composition's own declaration of the action, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this declaration (not already-registered); and the change is an object's declared action, not a runtime interface, a type surface or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug` (#24318)
6+
7+
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)
8+
9+
`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.
10+
11+
**BREAKING — what moves for consumers.** One string leaves a published constant, shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
- FROM: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` listed seven `sys_organization` actions, `'sys_organization.actions.change_slug'` among them.
14+
- TO: it lists six: `create_organization`, `update_organization`, `rename_organization`, `delete_organization`, `set_active_organization` and `leave_organization`.
15+
- 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.
16+
17+
**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.
18+
19+
<!-- adr-0087: not-required (no-migration-prescription) one path string leaves a published census constant that only the platform objects' own completeness guard reads: no authorable spec key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this row (not already-registered); and the export stays, its declared entry type unchanged; the `as const` literal tuple loses one member, which nothing in this repository names (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

‎packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* #7309 — translation-carryover guard for the 14 confirm questions moved from
5-
* `confirmText` to `description`.
4+
* #7309 — translation-carryover guard for the confirm questions moved from
5+
* `confirmText` to `description` (14 then, 13 the platform still declares).
66
*
77
* The wording did not change; the KEY did. `os i18n extract` treats a renamed
88
* key as a brand-new gap, and this repo's `i18n:extract` script runs with
@@ -32,7 +32,12 @@ const LOCALES = [
3232
['es-ES', esESObjects],
3333
] as const;
3434

35-
/** The 14 (object, action) pairs #7309 converted. */
35+
/**
36+
* The (object, action) pairs #7309 converted that this package still
37+
* declares: 13 of its 14. `sys_organization.change_slug` left the platform
38+
* object (its door is the cloud orchestrator's, and the composition that
39+
* mounts it declares it), so its strings left these bundles with it.
40+
*/
3641
const PAIRS: readonly (readonly [string, string])[] = [
3742
['sys_user', 'ban_user'],
3843
['sys_user', 'delete_my_account'],
@@ -41,7 +46,6 @@ const PAIRS: readonly (readonly [string, string])[] = [
4146
['sys_two_factor', 'disable_two_factor'],
4247
['sys_two_factor', 'regenerate_backup_codes'],
4348
['sys_account', 'unlink_account'],
44-
['sys_organization', 'change_slug'],
4549
['sys_team_member', 'remove_team_member'],
4650
['sys_oauth_application', 'enable_oauth_application'],
4751
['sys_oauth_application', 'disable_oauth_application'],
@@ -78,6 +82,15 @@ describe('#7309 — the moved confirm questions stay translated', () => {
7882
}
7983
});
8084

85+
it('the pinned set is 13 pairs, and no locale still carries sys_organization.change_slug', () => {
86+
expect(PAIRS).toHaveLength(13);
87+
for (const [locale, bundle] of [['en', enObjects] as const, ...LOCALES]) {
88+
expect(node(bundle, 'sys_organization', 'change_slug'), `${locale} sys_organization.change_slug`).toBeUndefined();
89+
// The control: the sibling actions of the same object are still there.
90+
expect(node(bundle, 'sys_organization', 'rename_organization'), `${locale} sys_organization.rename_organization`).toBeDefined();
91+
}
92+
});
93+
8194
it('no locale left the retired `confirmText` behind on these actions', () => {
8295
for (const [locale, bundle] of LOCALES) {
8396
for (const [obj, act] of PAIRS) {

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -605,11 +605,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
605605
label: "Rename Organization",
606606
successMessage: "Organization renamed"
607607
},
608-
change_slug: {
609-
label: "Change Slug",
610-
description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?",
611-
successMessage: "Organization slug changed"
612-
},
613608
leave_organization: {
614609
label: "Leave Organization",
615610
confirmText: "Leave this organization? You will lose access to its environments and members.",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -605,11 +605,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
605605
label: "Renombrar organización",
606606
successMessage: "Organización renombrada"
607607
},
608-
change_slug: {
609-
label: "Cambiar slug",
610-
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?",
611-
successMessage: "Slug de la organización cambiado"
612-
},
613608
leave_organization: {
614609
label: "Abandonar organización",
615610
confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -605,11 +605,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
605605
label: "組織名を変更",
606606
successMessage: "組織名を変更しました"
607607
},
608-
change_slug: {
609-
label: "スラッグを変更",
610-
description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?",
611-
successMessage: "組織のスラッグを変更しました"
612-
},
613608
leave_organization: {
614609
label: "組織から退出",
615610
confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -605,11 +605,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
605605
label: "重命名组织",
606606
successMessage: "组织已重命名"
607607
},
608-
change_slug: {
609-
label: "修改标识符",
610-
description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?",
611-
successMessage: "组织标识符已修改"
612-
},
613608
leave_organization: {
614609
label: "退出组织",
615610
confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。",

‎packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ const byName = (obj: any, name: string) => {
5454
return a;
5555
};
5656

57-
/** The 14 sites #7309 converted: object → action names. */
57+
/**
58+
* The sites #7309 converted that this package still declares: object → action
59+
* names. #7309 converted 14; the fifteenth row it carried,
60+
* `sys_organization.change_slug`, left the platform object, because its door
61+
* is the cloud orchestrator's and the composition that mounts it declares it.
62+
*/
5863
const CONVERTED: Record<string, string[]> = {
5964
sys_user: ['ban_user', 'delete_my_account', 'disable_two_factor', 'generate_backup_codes'],
6065
sys_oauth_application: [
@@ -63,7 +68,6 @@ const CONVERTED: Record<string, string[]> = {
6368
],
6469
sys_two_factor: ['disable_two_factor', 'regenerate_backup_codes'],
6570
sys_account: ['unlink_account'],
66-
sys_organization: ['change_slug'],
6771
sys_sso_provider: ['delete_sso_provider'],
6872
sys_team_member: ['remove_team_member'],
6973
};

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

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
//
66
// `update_organization` renames too, but it is declared at `list_item` only (a
77
// record page's header never offers it) and it bundles `slug`, which the cloud
8-
// orchestrator's `change_slug` governs where it exists. So the record page
9-
// needs its own door for `name` and `logo`, one that sends no slug.
8+
// orchestrator's slug action governs where it exists — an action the
9+
// composition serving that route declares itself, never this object. So the
10+
// record page needs its own door for `name` and `logo`, one that sends no slug.
1011
//
1112
// What this file pins, from source:
1213
// - the new action's declaration: its params are exactly `name` and `logo`,
@@ -16,10 +17,12 @@
1617
// `organization/update` (owner, admin);
1718
// - one door per field on the record header: no `record_header` action that
1819
// targets `organization/update` carries a `slug` param;
19-
// - the header order: the rename takes an inline slot and `change_slug` folds
20-
// into the overflow (the ordering model is described in
20+
// - the header order: the rename takes an inline slot and leave folds into
21+
// the overflow (the ordering model is described in
2122
// `sys-organization-invite-action.test.ts`);
22-
// - controls: `update_organization` and `change_slug` are unchanged.
23+
// - every door this object declares is better-auth's: no action targets the
24+
// cloud orchestrator, whose route no package in this repository serves;
25+
// - control: `update_organization`, the open-source slug door, is unchanged.
2326
//
2427
// The runtime half, a rename through better-auth that reads the slug back
2528
// unchanged, is `organization-rename-door.dogfood.test.ts` in
@@ -117,29 +120,35 @@ describe('sys_organization: one door per field on the record header', () => {
117120
}
118121
});
119122

120-
it('the rename takes an inline header slot and change_slug folds into the overflow', () => {
123+
it('the rename takes an inline header slot and leave_organization folds into the overflow', () => {
121124
const order = headerOrder();
122-
const names = actions().map((a) => a.name);
123-
expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug'));
124-
expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0);
125+
expect(order).toEqual(['invite_user', 'set_active_organization', 'rename_organization', 'leave_organization']);
125126
expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE);
126-
expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE);
127-
expect(order[order.length - 1]).toBe('leave_organization');
127+
expect(order.indexOf('leave_organization')).toBeGreaterThanOrEqual(MAX_VISIBLE);
128128
});
129129
});
130130

131-
describe('sys_organization: the slug doors are unchanged (controls)', () => {
131+
describe('sys_organization: every door it declares is one an open-source composition mounts', () => {
132+
it("every api action targets better-auth's organization routes, and none the cloud orchestrator", () => {
133+
const api = actions().filter((a) => a.type === 'api');
134+
expect(api.map((a) => a.name).sort()).toEqual([
135+
'create_organization', 'delete_organization', 'invite_user', 'leave_organization',
136+
'rename_organization', 'set_active_organization', 'update_organization',
137+
]);
138+
for (const a of api) {
139+
expect(a.target, a.name).toMatch(/^\/api\/v1\/auth\/organization\/[a-z-]+$/);
140+
}
141+
// The slug action the cloud orchestrator serves is declared by the
142+
// composition that mounts its route, bound here by `objectName`.
143+
expect(actions().map((a) => a.name)).not.toContain('change_slug');
144+
});
145+
});
146+
147+
describe('sys_organization: the open-source slug door is unchanged (control)', () => {
132148
it('update_organization keeps name / slug / logo, its target and list_item alone', () => {
133149
const update = action('update_organization');
134150
expect(update.locations).toEqual(['list_item']);
135151
expect(update.target).toBe(UPDATE_TARGET);
136152
expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']);
137153
});
138-
139-
it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => {
140-
const change = action('change_slug');
141-
expect(change.locations).toEqual(['record_header']);
142-
expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug');
143-
expect(paramKeys(change)).toEqual(['slug']);
144-
});
145154
});

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

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,9 @@ export const SysOrganization = ObjectSchema.create({
202202
// `invite_user` leading, the header's three inline slots go to the
203203
// invite, `set_active_organization` and `rename_organization`, and this
204204
// one folds into the `⋯` overflow's destructive band (objectui's
205-
// page:header split, default `maxVisible` 3), with `change_slug` in the
206-
// overflow above it. Without it, registration order kept it inline as
207-
// the third button. Ungated by grade: every grade may leave.
205+
// page:header split, default `maxVisible` 3). Without it, registration
206+
// order kept it inline as the third button. Ungated by grade: every
207+
// grade may leave.
208208
order: 1,
209209
mode: 'custom',
210210
locations: ['list_item', 'record_header'],
@@ -226,15 +226,13 @@ export const SysOrganization = ObjectSchema.create({
226226
// `beforeUpdateOrganization` slug guard both run only on a sent slug.
227227
//
228228
// One door per field. A record page reaches no `list_item` action, so
229-
// this one is declared at `record_header` too; where the cloud
230-
// orchestrator governs the slug, that page offers this action and
231-
// `change_slug`, and nothing on it sends a slug to better-auth.
232-
// `update_organization` stays the slug door of a composition without the
233-
// orchestrator, because `change_slug`'s target exists only in cloud.
234-
//
235-
// Declared BEFORE `change_slug`: both sort at `order` 0, so declaration
236-
// order breaks the tie, the rename takes the header's third inline slot,
237-
// and `change_slug` folds into the `⋯` overflow.
229+
// this one is declared at `record_header` too, and nothing on that page
230+
// sends a slug to better-auth. `update_organization` is the slug door of
231+
// a composition without the cloud orchestrator. Where the orchestrator
232+
// governs the slug, the composition that serves its route declares the
233+
// slug action itself, bound to this object by `objectName` and attached
234+
// to it as served; this object declares no action whose door only one
235+
// composition mounts.
238236
name: 'rename_organization',
239237
label: 'Rename Organization',
240238
icon: 'pencil',
@@ -255,36 +253,6 @@ export const SysOrganization = ObjectSchema.create({
255253
{ field: 'logo', defaultFromRow: true },
256254
],
257255
},
258-
{
259-
// Rename the organization slug (URL prefix). Backed by the cloud
260-
// orchestrator at /api/v1/cloud/organizations/{id}/change-slug,
261-
// which atomically updates sys_organization.slug, rewrites
262-
// platform_subdomain sys_domain rows under the new slug, soft-
263-
// retires the old rows with a redirect window, parks the old
264-
// slug in sys_slug_reservation, and refreshes the registry
265-
// mirror. See cloud `docs/design/sys-domain.md` §6.
266-
name: 'change_slug',
267-
label: 'Change Slug',
268-
icon: 'edit-3',
269-
variant: 'secondary',
270-
mode: 'custom',
271-
locations: ['record_header'],
272-
type: 'api',
273-
target: '/api/v1/cloud/organizations/{id}/change-slug',
274-
method: 'POST',
275-
requiresFeature: 'multiOrgEnabled',
276-
// Confirm question on `description`, not `confirmText`: this action collects
277-
// params, and pairing the two keys opens two dialogs for one decision
278-
// (#7278 ruling 2026-08-10, swept by #7309). The two param-LESS actions
279-
// above (`delete_organization`, `leave_organization`) keep `confirmText`
280-
// — with no param dialog, the confirm IS the only dialog.
281-
description: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?',
282-
successMessage: 'Organization slug changed',
283-
refreshAfter: true,
284-
params: [
285-
{ field: 'slug', required: true, defaultFromRow: true },
286-
],
287-
},
288256
],
289257

290258
listViews: {

0 commit comments

Comments
 (0)