Skip to content

Commit 1173017

Browse files
feat(platform-objects): sys_organization declares a name/logo-only rename action at the record header (#24317)
Fixes #24271 Clause-②: yes Dispatched by the `domain:services` seat 1 (seat post #6021), claim `6248546668` as amended by the fork ruling `6248721024`. Branch `claude/issue-24271-organization-rename-door`. ## What `sys_organization` declares `rename_organization` ("Rename Organization"), so an organization record page can rename the organization. The maintainer's question that filed the card: 「组织 slug 可以改,但是名称不能改吗」. - **The action.** `type: 'api'`, `target: '/api/v1/auth/organization/update'`, `recordIdParam: 'organizationId'`, `bodyShape: { wrap: 'data' }`, `requiresFeature: 'multiOrgEnabled'`, `locations: ['record_header', 'list_item']`. Its params are `name` (required, `defaultFromRow`) and `logo` (`defaultFromRow`). It has no `slug` param. It also carries `variant: 'secondary'` like its header siblings, `mode: 'edit'` like `update_organization`, icon `pencil`, and the success message "Organization renamed". - **The header order (ruled).** It is declared before `change_slug`. Both sort at `order` 0, so declaration order breaks the tie. The record header's inline row is now `invite_user`, `set_active_organization`, `rename_organization`. `change_slug` folds into the `⋯` overflow, and `leave_organization` (`order: 1`) stays last. The `leave_organization` comment that names the inline row is updated. So is the `actions` block's header comment: it said better-auth's columns are edited "ONLY through `update_organization`", and it now names both doors. - **Unchanged:** `update_organization` keeps `name`, `slug` and `logo`, its target and `list_item` alone. It stays the slug door of a composition without the cloud orchestrator. `change_slug` is not edited. Nothing in `plugin-auth` changes. - **Translations.** The four bundles were regenerated by `node scripts/check-i18n-bundles.mjs --write --filter=platform-objects`. The zh-CN, ja-JP and es-ES values are translated: 重命名组织 / 组织已重命名, 組織名を変更 / 組織名を変更しました, Renombrar organización / Organización renombrada. A second extract then dropped the six provenance rows the first one had seeded, so no copied English leaf ships. ## The spec row (the contract face) The only spec edit is one line in `packages/spec/src/kernel/public-auth-features.ts`. It adds `'sys_organization.actions.rename_organization'` to `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`'s row. - **What it books.** `requiresFeature: 'multiOrgEnabled'` lowers at parse time to `visible: features.multiOrgEnabled != false`. `PUBLIC_AUTH_FEATURES` records, for each flag `/api/v1/auth/config` serves, which spec inputs that flag gates. - **Why the guard needs it.** `packages/platform-objects/src/feature-gate-guard.test.ts` holds the registry and the objects' gates in lockstep in both directions. Forward: every booked path must carry the flag's predicate. Reverse: every `features.X` reference must be booked. The action without the row is red in the reverse direction; the previous round measured it (`sys_organization.actions.rename_organization is booked in multiOrgEnabled.gatedInputs: expected false to be true`). The row without the action is red in the forward direction. So the two land together. - **What it does not do.** It adds no schema key, no enum value, no `requiresFeature` value and no flag. `check:api-surface` and `check:authorable-surface` are green with no regenerated artifact. `@objectstack/spec` takes a `patch`. Precedent: `b0a1df044` booked `sys_organization.actions.invite_user` the same way. ## Premises, verified before any edit - **P1 holds** (better-auth 1.7.3, installed). `organization/update` takes `data` as a `.partial()` object where `name` and `slug` are optional and `logo` is nullish (`dist/plugins/organization/routes/crud-org.mjs`). The slug-taken check runs only when `data.slug` is a string. `adapter.updateOrganization` writes `...data` plus `metadata`, so a body without `slug` leaves the column alone. **Measured on a boot:** a name-only body answered 200 and returned the old slug, and the booted pin below reads the slug back unchanged after a name and logo rename. - **P3 holds.** plugin-auth's `beforeUpdateOrganization` returns at its first line when no slug is sent (`auth-manager.ts:3607`–`:3610`). The ADR-0092 D2 identity write guard passes better-auth adapter writes: the adapter's writes run as attributed system, so `isUserContextWrite` is false. better-auth grants `organization: ['update']` to `owner` and `admin` (`access/statement.mjs`), not to `member`. **Measured on a boot for the owner:** the creator's rename answered 200. The admin grade is a source reading. ## Tests All on head `1c3722ca1` unless noted. - **Declaration pin**, new: `packages/platform-objects/src/identity/sys-organization-rename-action.test.ts`, 8 tests. It pins the params (exactly `name` and `logo`), the locations, the target, `recordIdParam` and `bodyShape` (equal to `update_organization`'s), and the lowered gate. It pins one door per field: the only `record_header` action that writes through `organization/update` is the rename, and it collects no slug. It pins the header order, with the rename inline and `change_slug` past the inline budget. Controls: `update_organization` and `change_slug` unchanged. - **Matrix row:** `platform-objects.test.ts`'s feature-gate lowering matrix gains `rename_organization` at `features.multiOrgEnabled != false`. - **Booted pin**, new: `packages/qa/dogfood/test/organization-rename-door.dogfood.test.ts`, 2 tests. It boots the showcase with `multiTenant: 'posture-only'`, creates an organization, builds the request from the REGISTERED action the way the console's api runner does, renames it, and reads `slug` back unchanged. `organization-update-door.dogfood.test.ts` ran beside it as the control: 11 of 11 passed. - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2`: `Test Files 76 passed (76)`, `Tests 1176 passed (1176)`. The package's `typecheck` exits 0, and `tsc -p tsconfig.test.json --listFilesOnly` lists the new test. - `@objectstack/spec`: `vitest run --project local` gives `Test Files 694 passed (694)`, `Tests 21040 passed | 1 todo`. `typecheck` exits 0. The `repo` project is CI's. - `plugin-auth` `src/public-feature-registry.test.ts` (the served-key drift guard): 5 of 5 passed. - **Ablation, once** (through `scripts/ablation-replace.mjs`). A `slug` param was planted in `rename_organization` and the anchor count went 1 to 0. The declaration pin then answered `Tests 2 failed | 6 passed`: "expected [ 'name', 'slug', 'logo' ] to deeply equal [ 'name', 'logo' ]" and "rename_organization sends a slug to better-auth". The restore was proven by blob equality: `16a3dce65796` equals HEAD, and `git diff HEAD` is empty. The pin imports the object from source, so no build sits between the mutation and the run. ## Gates The local subset is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` over this diff: 88 commands, each run with its exit code captured before any pipe. 87 exited 0. `pnpm check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3): it reads every workspace package's `dist/`, and eight packages outside this diff's build closure have none. It is recorded NOT-MEASURED, and CI runs it. The `--ran` reconciliation reads `88 derived famil(ies) accounted for — 87 run, 1 NOT-MEASURED`. The local-only `check:dev-prereqs` passed after the spec build. ## Acceptance notes - **`change_slug` in the open-source composition, measured:** `POST /api/v1/cloud/organizations/{id}/change-slug` answers `404 ENDPOINT_NOT_FOUND` on a booted showcase. On the stock single-org boot, `/auth/config` serves `multiOrgEnabled: false`, so the action is hidden there. On the `posture-only` boot it serves `true`, so the record page offers `change_slug` (now in the overflow) and the click answers that 404. This is handed to the seat as a finding, and nothing is changed here. - **The second slug door at the API level on a cloud composition:** better-auth `organization/update` still accepts `data.slug`, and `beforeUpdateOrganization` refuses it only while active environments exist. The record page no longer reaches it (the rename sends no slug). The API door is objectstack-ai/cloud#3000's to carry. - `packages/platform-objects/src/pages/sys-organization.page.ts`'s header comment lists "Set Active, Edit, Delete, Leave" as the record-header actions. Edit and Delete are `list_item` only. This is comment drift, outside this claim's file surface, and is left as it is. - The cloud organization page lists its header actions explicitly. It gains this action in its own card once cloud's framework pin carries it. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ef0ee8 commit 1173017

10 files changed

Lines changed: 337 additions & 5 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
`sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu.
7+
8+
`update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged.
9+
10+
`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`.
11+
12+
Clause-②: yes

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
601601
label: "Set Active",
602602
successMessage: "Active organization switched"
603603
},
604+
rename_organization: {
605+
label: "Rename Organization",
606+
successMessage: "Organization renamed"
607+
},
604608
change_slug: {
605609
label: "Change Slug",
606610
description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
601601
label: "Establecer como activa",
602602
successMessage: "Organización activa cambiada"
603603
},
604+
rename_organization: {
605+
label: "Renombrar organización",
606+
successMessage: "Organización renombrada"
607+
},
604608
change_slug: {
605609
label: "Cambiar slug",
606610
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?",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
601601
label: "アクティブに設定",
602602
successMessage: "アクティブ組織を切り替えました"
603603
},
604+
rename_organization: {
605+
label: "組織名を変更",
606+
successMessage: "組織名を変更しました"
607+
},
604608
change_slug: {
605609
label: "スラッグを変更",
606610
description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
601601
label: "设为当前",
602602
successMessage: "当前组织已切换"
603603
},
604+
rename_organization: {
605+
label: "重命名组织",
606+
successMessage: "组织已重命名"
607+
},
604608
change_slug: {
605609
label: "修改标识符",
606610
description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// sys_organization's rename door: a name/logo-only action a record page can
4+
// offer.
5+
//
6+
// `update_organization` renames too, but it is declared at `list_item` only (a
7+
// 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.
10+
//
11+
// What this file pins, from source:
12+
// - the new action's declaration: its params are exactly `name` and `logo`,
13+
// it sits at `record_header` and `list_item`, it targets better-auth's
14+
// `organization/update` exactly as `update_organization` does, and it is
15+
// gated on `multiOrgEnabled` like every sibling organization action;
16+
// - one door per field on the record header: no `record_header` action that
17+
// targets `organization/update` carries a `slug` param;
18+
// - the header order: the rename takes an inline slot and `change_slug` folds
19+
// into the overflow (the ordering model is described in
20+
// `sys-organization-invite-action.test.ts`);
21+
// - controls: `update_organization` and `change_slug` are unchanged.
22+
//
23+
// The runtime half, a rename through better-auth that reads the slug back
24+
// unchanged, is `organization-rename-door.dogfood.test.ts` in
25+
// `packages/qa/dogfood`.
26+
import { describe, expect, it } from 'vitest';
27+
import { featureGatePredicate } from '@objectstack/spec/kernel';
28+
import { SysOrganization } from './sys-organization.object.js';
29+
30+
interface DeclaredParam {
31+
field?: string;
32+
name?: string;
33+
required?: boolean;
34+
defaultFromRow?: boolean;
35+
}
36+
37+
interface DeclaredAction {
38+
name?: string;
39+
type?: string;
40+
target?: string;
41+
method?: string;
42+
recordIdParam?: string;
43+
bodyShape?: unknown;
44+
order?: number;
45+
locations?: string[];
46+
visible?: { source?: string };
47+
params?: DeclaredParam[];
48+
}
49+
50+
const UPDATE_TARGET = '/api/v1/auth/organization/update';
51+
52+
/** objectui page:header's default desktop inline budget. */
53+
const MAX_VISIBLE = 3;
54+
55+
const actions = (): DeclaredAction[] =>
56+
((SysOrganization as { actions?: DeclaredAction[] }).actions) ?? [];
57+
58+
function action(name: string): DeclaredAction {
59+
const found = actions().find((a) => a.name === name);
60+
expect(found, `${name} is declared`).toBeDefined();
61+
return found as DeclaredAction;
62+
}
63+
64+
const paramKeys = (a: DeclaredAction): string[] => (a.params ?? []).map((p) => (p.field ?? p.name) as string);
65+
66+
/** The object's `record_header` actions, in the order the stable `order` sort gives them. */
67+
function headerOrder(): string[] {
68+
return actions()
69+
.filter((a) => (a.locations ?? []).includes('record_header'))
70+
.map((a, i) => ({ a, i }))
71+
.sort((x, y) => (x.a.order ?? 0) - (y.a.order ?? 0) || x.i - y.i)
72+
.map(({ a }) => a.name as string);
73+
}
74+
75+
describe('sys_organization.rename_organization: the declaration', () => {
76+
it('collects exactly `name` (required) and `logo`, both pre-filled from the row, and no `slug`', () => {
77+
const rename = action('rename_organization');
78+
expect(paramKeys(rename)).toEqual(['name', 'logo']);
79+
const [name, logo] = rename.params ?? [];
80+
expect(name).toMatchObject({ field: 'name', required: true, defaultFromRow: true });
81+
expect(logo).toMatchObject({ field: 'logo', defaultFromRow: true });
82+
expect(logo?.required).not.toBe(true);
83+
});
84+
85+
it('is offered at the record header and on the list row', () => {
86+
expect(action('rename_organization').locations).toEqual(['record_header', 'list_item']);
87+
});
88+
89+
it("targets better-auth's organization/update exactly as update_organization does", () => {
90+
const rename = action('rename_organization');
91+
const update = action('update_organization');
92+
expect(rename.type).toBe('api');
93+
expect(rename.target).toBe(UPDATE_TARGET);
94+
expect(rename.recordIdParam).toBe('organizationId');
95+
expect(rename.bodyShape).toEqual({ wrap: 'data' });
96+
expect({ type: rename.type, target: rename.target, method: rename.method, recordIdParam: rename.recordIdParam, bodyShape: rename.bodyShape })
97+
.toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape });
98+
});
99+
100+
it('is gated on multiOrgEnabled, the same lowered predicate as update_organization', () => {
101+
const gate = featureGatePredicate('multiOrgEnabled');
102+
expect(action('rename_organization').visible?.source).toBe(gate);
103+
expect(action('update_organization').visible?.source).toBe(gate);
104+
});
105+
});
106+
107+
describe('sys_organization: one door per field on the record header', () => {
108+
it('no record_header action that writes through organization/update collects a slug', () => {
109+
const headerWriters = actions()
110+
.filter((a) => (a.locations ?? []).includes('record_header'))
111+
.filter((a) => a.target === UPDATE_TARGET);
112+
expect(headerWriters.map((a) => a.name)).toEqual(['rename_organization']);
113+
for (const a of headerWriters) {
114+
expect(paramKeys(a), `${a.name} sends a slug to better-auth`).not.toContain('slug');
115+
}
116+
});
117+
118+
it('the rename takes an inline header slot and change_slug folds into the overflow', () => {
119+
const order = headerOrder();
120+
const names = actions().map((a) => a.name);
121+
expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug'));
122+
expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0);
123+
expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE);
124+
expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE);
125+
expect(order[order.length - 1]).toBe('leave_organization');
126+
});
127+
});
128+
129+
describe('sys_organization: the slug doors are unchanged (controls)', () => {
130+
it('update_organization keeps name / slug / logo, its target and list_item alone', () => {
131+
const update = action('update_organization');
132+
expect(update.locations).toEqual(['list_item']);
133+
expect(update.target).toBe(UPDATE_TARGET);
134+
expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']);
135+
});
136+
137+
it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => {
138+
const change = action('change_slug');
139+
expect(change.locations).toEqual(['record_header']);
140+
expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug');
141+
expect(paramKeys(change)).toEqual(['slug']);
142+
});
143+
});

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export const SysOrganization = ObjectSchema.create({
5555

5656
// Custom actions — generic create / delete are suppressed (better-auth-
5757
// managed), and better-auth's own columns (`name`, `slug`, `logo`) are
58-
// edited ONLY through `update_organization` below. The generic `update` the
58+
// edited ONLY through better-auth's `organization/update`: the
59+
// `update_organization` row action (all three) and the name/logo-only
60+
// `rename_organization` (record header and row) below. The generic `update` the
5961
// data door admits since #15873 reaches the platform-owned extension columns
6062
// alone (see `userActions` above and `enable.apiMethods` at the bottom);
6163
// admins still need to create new orgs from the Setup app.
@@ -194,10 +196,11 @@ export const SysOrganization = ObjectSchema.create({
194196
variant: 'danger',
195197
// Higher than every sibling (unset = 0), so it sorts LAST: with
196198
// `invite_user` leading, the header's three inline slots go to the
197-
// invite, `set_active_organization` and `change_slug`, and this one
198-
// folds into the `⋯` overflow's destructive band (objectui's
199-
// page:header split, default `maxVisible` 3). Without it, registration
200-
// order kept it inline as the third button.
199+
// invite, `set_active_organization` and `rename_organization`, and this
200+
// one folds into the `⋯` overflow's destructive band (objectui's
201+
// page:header split, default `maxVisible` 3), with `change_slug` in the
202+
// overflow above it. Without it, registration order kept it inline as
203+
// the third button.
201204
order: 1,
202205
mode: 'custom',
203206
locations: ['list_item', 'record_header'],
@@ -210,6 +213,42 @@ export const SysOrganization = ObjectSchema.create({
210213
successMessage: 'You have left the organization',
211214
refreshAfter: true,
212215
},
216+
{
217+
// Rename the organization: its `name` and `logo`, never its `slug`.
218+
// The same better-auth door as `update_organization` above
219+
// (`organization/update`, fields nested under `data`), with no `slug`
220+
// param, so the body carries no slug and the stored one is kept:
221+
// better-auth's slug-taken check and plugin-auth's
222+
// `beforeUpdateOrganization` slug guard both run only on a sent slug.
223+
//
224+
// One door per field. A record page reaches no `list_item` action, so
225+
// this one is declared at `record_header` too; where the cloud
226+
// orchestrator governs the slug, that page offers this action and
227+
// `change_slug`, and nothing on it sends a slug to better-auth.
228+
// `update_organization` stays the slug door of a composition without the
229+
// orchestrator, because `change_slug`'s target exists only in cloud.
230+
//
231+
// Declared BEFORE `change_slug`: both sort at `order` 0, so declaration
232+
// order breaks the tie, the rename takes the header's third inline slot,
233+
// and `change_slug` folds into the `⋯` overflow.
234+
name: 'rename_organization',
235+
label: 'Rename Organization',
236+
icon: 'pencil',
237+
variant: 'secondary',
238+
mode: 'edit',
239+
locations: ['record_header', 'list_item'],
240+
type: 'api',
241+
target: '/api/v1/auth/organization/update',
242+
recordIdParam: 'organizationId',
243+
bodyShape: { wrap: 'data' },
244+
requiresFeature: 'multiOrgEnabled',
245+
successMessage: 'Organization renamed',
246+
refreshAfter: true,
247+
params: [
248+
{ field: 'name', required: true, defaultFromRow: true },
249+
{ field: 'logo', defaultFromRow: true },
250+
],
251+
},
213252
{
214253
// Rename the organization slug (URL prefix). Backed by the cloud
215254
// orchestrator at /api/v1/cloud/organizations/{id}/change-slug,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ describe('feature-gate lowering matrix (#2874)', () => {
586586
const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [
587587
['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG],
588588
['SysOrganization', SysOrganization, 'update_organization', MULTI_ORG],
589+
['SysOrganization', SysOrganization, 'rename_organization', MULTI_ORG],
589590
['SysOrganization', SysOrganization, 'delete_organization', MULTI_ORG],
590591
['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG],
591592
['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG],
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_organization.rename_organization` through the door it targets, on a
5+
* booted showcase.
6+
*
7+
* The record page's rename door is a name/logo-only action on better-auth's
8+
* `organization/update`, the endpoint `update_organization` uses, declared
9+
* without a `slug` param. Its whole claim is that a rename through it keeps the
10+
* stored slug: better-auth writes only the keys it is sent, its slug-taken
11+
* check runs only on a sent slug, and plugin-auth's `beforeUpdateOrganization`
12+
* slug guard returns at once when no slug is sent. This file proves that claim
13+
* against the real stack instead of a source reading.
14+
*
15+
* The request is built from the REGISTERED action, the way the console's api
16+
* action runner builds it (objectui `useConsoleActionRuntime`): the params go
17+
* under `bodyShape.wrap`, the record id under `recordIdParam`, and the target is
18+
* the declared one. So a slug param added to the declaration would reach the
19+
* body here, and the slug assertion below would see what a user would get.
20+
*
21+
* The organization is minted the way `organization-update-door.dogfood.test.ts`
22+
* mints it: `multiTenant: 'posture-only'` opens `organization/create` on the
23+
* plain showcase (it activates the posture, never the wall), and the creator is
24+
* the owner, a grade better-auth grants `organization: ['update']`.
25+
*
26+
* The declaration half (params, locations, gate, header order, and the two
27+
* slug doors unchanged) is `sys-organization-rename-action.test.ts` in
28+
* `@objectstack/platform-objects`.
29+
*/
30+
31+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
32+
import { type VerifyStack } from '@objectstack/verify';
33+
import { bootShowcase } from './showcase-boot.js';
34+
35+
interface RegisteredAction {
36+
name?: string;
37+
type?: string;
38+
target?: string;
39+
recordIdParam?: string;
40+
bodyShape?: { wrap?: string };
41+
params?: Array<{ field?: string; name?: string }>;
42+
}
43+
44+
const SLUG = 'rename-door-org';
45+
46+
describe('sys_organization.rename_organization: a rename through better-auth keeps the slug', () => {
47+
let stack: VerifyStack;
48+
let token: string;
49+
let orgId: string;
50+
let rename: RegisteredAction;
51+
52+
/** The organization row as the data door serves it back. */
53+
const readOrg = async (id: string): Promise<Record<string, unknown>> => {
54+
const res = await stack.apiAs(token, 'GET', `/data/sys_organization/${id}`);
55+
expect(res.status).toBe(200);
56+
const body: any = await res.json();
57+
return (body.record ?? body.data ?? {}) as Record<string, unknown>;
58+
};
59+
60+
/** Run the registered action the way the console's api runner does. */
61+
const runRename = async (values: Record<string, unknown>) => {
62+
const keys = (rename.params ?? []).map((p) => (p.field ?? p.name) as string);
63+
const collected = Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]]));
64+
const wrap = rename.bodyShape?.wrap;
65+
const body: Record<string, unknown> = wrap ? { [wrap]: collected } : { ...collected };
66+
body[rename.recordIdParam as string] = orgId;
67+
// `apiAs` paths are relative to the REST prefix the target names.
68+
const path = String(rename.target).replace(/^\/api\/v1/, '');
69+
return stack.apiAs(token, 'POST', path, body);
70+
};
71+
72+
beforeAll(async () => {
73+
stack = await bootShowcase({ multiTenant: 'posture-only' });
74+
token = await stack.signIn();
75+
76+
const created = await stack.apiAs(token, 'POST', '/auth/organization/create', {
77+
name: 'Rename Door Org',
78+
slug: SLUG,
79+
});
80+
expect(created.status, `organization/create returned ${created.status}: ${await created.clone().text()}`).toBe(200);
81+
orgId = String(((await created.json()) as { id?: string }).id);
82+
expect(orgId).toBeTruthy();
83+
84+
const engine = await stack.kernel.getServiceAsync<any>('objectql');
85+
const schema = engine?.getSchema?.('sys_organization');
86+
rename = ((schema?.actions ?? []) as RegisteredAction[]).find((a) => a?.name === 'rename_organization') as RegisteredAction;
87+
expect(rename, 'rename_organization is registered').toBeTruthy();
88+
}, 120_000);
89+
90+
afterAll(async () => { await stack?.stop?.(); });
91+
92+
it('the registered action is the declared one: organization/update, name and logo under data, the row id as organizationId', () => {
93+
expect(rename.type).toBe('api');
94+
expect(rename.target).toBe('/api/v1/auth/organization/update');
95+
expect(rename.bodyShape).toEqual({ wrap: 'data' });
96+
expect(rename.recordIdParam).toBe('organizationId');
97+
expect((rename.params ?? []).map((p) => p.field ?? p.name)).toEqual(['name', 'logo']);
98+
});
99+
100+
it('renames the organization and sets its logo, and the slug reads back unchanged', async () => {
101+
const before = await readOrg(orgId);
102+
expect(before.slug).toBe(SLUG);
103+
104+
// A new slug is among the values on purpose. The param dialog collects
105+
// declared params alone, so with no `slug` param it never reaches the
106+
// body, and the stored slug must survive the rename.
107+
const res = await runRename({ name: 'Renamed Door Org', logo: 'https://example.com/rename-door.png', slug: 'smuggled-slug' });
108+
expect(res.status, `organization/update returned ${res.status}: ${await res.clone().text()}`).toBe(200);
109+
const answered: any = await res.json();
110+
expect(answered.name).toBe('Renamed Door Org');
111+
expect(answered.slug).toBe(SLUG);
112+
113+
const after = await readOrg(orgId);
114+
expect(after.name).toBe('Renamed Door Org');
115+
expect(after.logo).toBe('https://example.com/rename-door.png');
116+
expect(after.slug).toBe(SLUG);
117+
});
118+
});

0 commit comments

Comments
 (0)