diff --git a/.changeset/25347-rename-organization-auth-feature.md b/.changeset/25347-rename-organization-auth-feature.md new file mode 100644 index 00000000000..48e4f4bd78e --- /dev/null +++ b/.changeset/25347-rename-organization-auth-feature.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +feat(spec): `PUBLIC_AUTH_FEATURES` books Rename Organization under the organization capability + +Clause-②: yes +Metadata-protocol: none + +The `sys_organization.actions.rename_organization` row moves from `multiOrgEnabled.gatedInputs` to `organization.gatedInputs`, matching the action's new `requiresFeature: 'organization'` in `@objectstack/platform-objects`. A `single` deployment, where `multiOrgEnabled` is false and the organization capability is on, now offers it. No other row moves. + +Ruling 6384629949 on #25347. diff --git a/.changeset/25347-rename-organization-single.md b/.changeset/25347-rename-organization-single.md new file mode 100644 index 00000000000..ae9ad3baabc --- /dev/null +++ b/.changeset/25347-rename-organization-single.md @@ -0,0 +1,14 @@ +--- +'@objectstack/platform-objects': patch +--- + +feat(platform-objects): a single-organization deployment can rename its organization + +Clause-②: yes +Metadata-protocol: none + +- **`sys_organization`'s Rename Organization** (name and logo, never the slug) is now gated on the organization capability instead of `multiOrgEnabled`. Under the `single` posture it was the one organization's only possible name door, and it was hidden: the generic edit keeps `name` readonly and every other organization action is multi-organization only. It is still offered only to the organization's owners and admins, and better-auth's `organization/update` still decides at the door. +- **Edit Organization** (which also sends the slug), Create, Delete, Switch and Leave stay multi-organization only. Under `multi` nothing changes. +- **`sys_organization.name`'s help** now says the name is changed with Rename Organization, in all four bundles. + +Ruling 6384629949 on #25347. 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 bfbce4b4e01..2db7993743c 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -528,7 +528,7 @@ export const enObjects: NonNullable = { fields: { name: { label: "Name", - help: "The organization's own name. The product name the console shows is a different setting, set in Settings → Branding." + help: "The organization's own name. Change it with Rename Organization at the top of this record. The product name the console shows is a different setting, set in Settings → Branding." }, slug: { label: "Slug", 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 49f8eb07ed0..f14c59f83cb 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 @@ -528,7 +528,7 @@ export const esESObjects: NonNullable = { fields: { name: { label: "Nombre", - help: "El nombre propio de la organización. El nombre del producto que muestra la consola es otro ajuste, que se define en Configuración → Marca." + help: "El nombre propio de la organización. Se cambia con Renombrar organización, en la parte superior de este registro. El nombre del producto que muestra la consola es otro ajuste, que se define en Configuración → Marca." }, slug: { label: "Slug", 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 46616a89ce1..b7ef18f5619 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 @@ -528,7 +528,7 @@ export const jaJPObjects: NonNullable = { fields: { name: { label: "名前", - help: "組織自体の名前。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。" + help: "組織自体の名前。このレコード上部の「組織名を変更」で変更できます。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。" }, slug: { label: "スラッグ", 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 b8febdf3fd8..f2594feea6e 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 @@ -528,7 +528,7 @@ export const zhCNObjects: NonNullable = { fields: { name: { label: "名称", - help: "组织自身的名称。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。" + help: "组织自身的名称。可在此记录顶部用“重命名组织”修改。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。" }, slug: { label: "标识符", diff --git a/packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts b/packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts index ce58aee41b4..9b504724e42 100644 --- a/packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts +++ b/packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts @@ -6,7 +6,9 @@ // // - `name` is the organization's own name. The product name the console shows // is Settings → Branding's `workspace_name`, a separate setting, and the help -// says so. It names no door: the rename doors are multi-organization only. +// says so. It names the door that changes it, Rename Organization, which is +// offered under `single` too (ruling 6384629949; the gate is pinned in +// `sys-organization-rename-action.test.ts`). // - `timezone` is read by nothing in the platform (see its declaration). The // zone in effect is the settings door's `localization.timezone`, and the help // says so instead of claiming date boundaries are computed in this column. @@ -24,6 +26,12 @@ describe('sys_organization: the name and timezone fields say which value is whic expect(field('name').description).toMatch(/different setting, set in Settings → Branding/); }); + it('the name names the door that changes it, the action labelled Rename Organization', () => { + expect(field('name').description).toMatch(/Change it with Rename Organization/); + const rename = (SysOrganization.actions ?? []).find((a) => a.name === 'rename_organization'); + expect(rename?.label).toBe('Rename Organization'); + }); + it("the timezone says it changes no date calculation and that Localization's Default timezone wins", () => { const help = field('timezone').description ?? ''; expect(help).toMatch(/does not change any date calculation/); 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 5537261e045..257caf13e54 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 @@ -13,8 +13,9 @@ // - the new action's declaration: its params are exactly `name` and `logo`, // it sits at `record_header` and `list_item`, it targets better-auth's // `organization/update` exactly as `update_organization` does, and it is -// gated like it: on `multiOrgEnabled` and on the grades that reach -// `organization/update` (owner, admin); +// gated on the grades that reach `organization/update` (owner, admin) like +// it, but on the organization capability rather than `multiOrgEnabled`, so +// a `single` deployment can rename its one organization (#25347); // - one door per field on the record header: no `record_header` action that // targets `organization/update` carries a `slug` param; // - the header order: the rename takes an inline slot and leave folds into @@ -102,10 +103,11 @@ describe('sys_organization.rename_organization: the declaration', () => { .toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape }); }); - it('is gated on the update reach and multiOrgEnabled, the same lowered predicate as update_organization', () => { - const gate = `(${membershipReachPredicate('update_organization')}) && ${featureGatePredicate('multiOrgEnabled')}`; - expect(action('rename_organization').visible?.source).toBe(gate); - expect(action('update_organization').visible?.source).toBe(gate); + it('is gated on the update reach like update_organization, but on the organization capability, not multiOrgEnabled', () => { + const reach = `(${membershipReachPredicate('update_organization')})`; + expect(action('rename_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('organization')}`); + // CONTROL: the slug door stays multi-organization only. + expect(action('update_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('multiOrgEnabled')}`); }); }); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 358dfbfa731..e00ca0adfb3 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -245,7 +245,13 @@ export const SysOrganization = ObjectSchema.create({ target: '/api/v1/auth/organization/update', recordIdParam: 'organizationId', bodyShape: { wrap: 'data' }, - requiresFeature: 'multiOrgEnabled', + // [#25347, ruling 6384629949] Gated on the organization CAPABILITY, not + // on `multiOrgEnabled`: a `single` deployment has one organization and + // this is its only name door (the generic edit keeps `name` readonly, + // ADR-0092 D4). Renaming it changes nothing about tenancy, so it is not + // a multi-organization action; `update_organization`, which also sends + // the slug, stays multi-organization only. + requiresFeature: 'organization', // The same door as `update_organization`, so the same grades. requiresMembershipReach: 'update_organization', successMessage: 'Organization renamed', @@ -299,8 +305,9 @@ export const SysOrganization = ObjectSchema.create({ // better-auth's adapter (which stamps `isSystem`) still writes them. // [#25347] The organization's one name, and its help says what it is not: // the product name the console shows is Settings → Branding's - // `workspace_name`, a separate home. It names no door: the rename doors are - // multi-organization only, so under `single` none is offered. + // `workspace_name`, a separate home. It names the door that changes it, + // `rename_organization`, offered under every posture with organizations + // (`single` included, ruling 6384629949). name: Field.text({ label: 'Name', required: true, @@ -308,7 +315,8 @@ export const SysOrganization = ObjectSchema.create({ searchable: true, maxLength: 255, description: - 'The organization\'s own name. The product name the console shows is a different setting, set in Settings → Branding.', + 'The organization\'s own name. Change it with Rename Organization at the top of this record. ' + + 'The product name the console shows is a different setting, set in Settings → Branding.', group: 'Identity', }), diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index b60f2e1ba07..518bd6d2aeb 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -699,7 +699,9 @@ describe('feature-gate lowering matrix (#2874)', () => { // Their doors are better-auth's `organization/update` (owner, admin) and // `organization/delete` (owner), so the grade term leads the feature gate. ['SysOrganization', SysOrganization, 'update_organization', `${ADMINS} && ${MULTI_ORG}`], - ['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${MULTI_ORG}`], + // [#25347] The name/logo-only rename is on the capability: under `single` + // it is the one organization's only name door. + ['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${ORG}`], ['SysOrganization', SysOrganization, 'delete_organization', `(${OWNER}) && ${MULTI_ORG}`], ['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG], @@ -762,15 +764,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. + // The multi-org 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. `rename_organization` has a row on the capability gate instead + // (#25347), so the pinned side reads the rows' gate too. 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); + const pinned = rows + .filter(([exported, , , expected]) => exported === 'SysOrganization' && expected.includes(MULTI_ORG)) + .map(([, , name]) => name); expect([...gated].sort()).toEqual([...pinned].sort()); expect(gated).not.toContain('change_slug'); }); diff --git a/packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts b/packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts new file mode 100644 index 00000000000..22d79349202 --- /dev/null +++ b/packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `single` deployment can rename its one organization (#25347). + * + * One organization used to show three names: Settings → Branding's unsaved + * default, the organization record's "Default Organization", and the console's + * product name. The organization's own name has one home, `sys_organization.name`, + * and under `single` it had no door at all: `update_organization` and + * `rename_organization` were both gated on `multiOrgEnabled`, and the generic + * edit form keeps `name` readonly (ADR-0092 D4). `rename_organization` is now + * gated on the organization CAPABILITY, so the posture `os dev` boots by + * default offers it. + * + * Measured on the plain showcase (`single`), on the wire, from the three things + * the console combines: the SERVED flags (`GET /auth/config`), the SERVED + * session face (`GET /auth/get-session`, its `positions`) and the SERVED + * `sys_organization` metadata, each `visible` evaluated with the console's + * `celEngine` against the default organization's row. Then the door: the + * rename runs the way the console's api action runner builds it and the name + * reads back changed, the slug the default-organization bootstrap finds the + * row by unchanged. A plain member is offered nothing and refused at the door. + * + * The declaration half is `sys-organization-rename-action.test.ts` in + * `@objectstack/platform-objects`; the multi-organization grade matrix is + * `org-record-admin-reach.dogfood.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { celEngine } from '@objectstack/formula'; +import { bootShowcase } from './showcase-boot.js'; + +const SYSTEM_CTX = { isSystem: true }; + +interface ServedAction { + name: string; + type?: string; + target?: string; + recordIdParam?: string; + bodyShape?: { wrap?: string }; + params?: Array<{ field?: string; name?: string }>; + visible?: unknown; +} + +type Principal = 'admin' | 'member'; + +describe('sys_organization.rename_organization under single: the one organization can be renamed', () => { + let stack: VerifyStack; + let ql: any; + let features: Record; + let orgRow: Record; + const tokens = {} as Record; + const sessionUser = {} as Record>; + const served = {} as Record>; + + const readOrg = async (id: string): Promise> => { + const rows = await ql.find('sys_organization', { where: { id }, limit: 1 }, { context: SYSTEM_CTX }); + return ((Array.isArray(rows) ? rows : rows?.records ?? [])[0] ?? {}) as Record; + }; + + /** What the console offers this principal: the served action, its predicate admitting it. */ + const offered = (who: Principal, name: string): boolean => { + const action = served[who].get(name); + if (!action) return false; + if (action.visible === undefined) return true; + const result = celEngine.evaluate(action.visible as never, { + record: orgRow, + user: sessionUser[who] as never, + extra: { features }, + }); + if (!result.ok) throw new Error(`${name} faulted for ${who}: ${JSON.stringify(result)}`); + return result.value === true; + }; + + /** Run the served action the way the console's api runner does. */ + const runRename = (who: Principal, values: Record) => { + const rename = served[who].get('rename_organization')!; + const keys = (rename.params ?? []).map((p) => (p.field ?? p.name) as string); + const collected = Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]])); + const wrap = rename.bodyShape?.wrap; + const body: Record = wrap ? { [wrap]: collected } : { ...collected }; + body[rename.recordIdParam as string] = orgRow.id; + return stack.apiAs(tokens[who], 'POST', String(rename.target).replace(/^\/api\/v1/, ''), body); + }; + + beforeAll(async () => { + stack = await bootShowcase(); + ql = await stack.kernel.getServiceAsync('objectql'); + tokens.admin = await stack.signIn(); + tokens.member = await stack.signUp('org-rename-single.member@verify.test', 'OrgRenameSingle!123', 'Single member'); + + const config = await stack.apiAs(tokens.admin, 'GET', '/auth/config'); + expect(config.status).toBe(200); + features = ((await config.json()) as { data: { features: Record } }).data.features; + + for (const who of ['admin', 'member'] as const) { + const session = await stack.apiAs(tokens[who], 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + sessionUser[who] = ((await session.json()) as { user: Record }).user; + const meta = await stack.apiAs(tokens[who], 'GET', '/meta/object/sys_organization'); + expect(meta.status, `${who} reads /meta/object/sys_organization`).toBe(200); + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + served[who] = new Map((body.item?.actions ?? []).map((a) => [a.name, a])); + } + + // The organization the admin's session opens in: under `single`, the one + // membership the default-organization bootstrap gave the seeded admin. + const memberships = await ql.find('sys_member', { where: { user_id: sessionUser.admin.id }, limit: 5 }, { context: SYSTEM_CTX }); + const rows = (Array.isArray(memberships) ? memberships : memberships?.records ?? []) as Array>; + expect(rows, 'PREMISE: the seeded admin belongs to exactly one organization').toHaveLength(1); + orgRow = await readOrg(String(rows[0].organization_id)); + expect(orgRow.id, 'PREMISE: the default organization row exists').toBe(rows[0].organization_id); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('the composition is single: organizations exist, multi-organization is off', () => { + expect(features.multiOrgEnabled).toBe(false); + expect(features.organization).toBe(true); + const positions = sessionUser.admin.positions as string[]; + expect( + positions.includes('org_owner') || positions.includes('org_admin'), + `PREMISE: the seeded admin holds a grade organization/update admits (${JSON.stringify(positions)})`, + ).toBe(true); + }); + + it('the admin is offered Rename Organization, and still not the multi-organization actions', () => { + expect(offered('admin', 'rename_organization')).toBe(true); + // CONTROL: the slug door and the rest of the multi-organization set stay hidden. + for (const name of ['update_organization', 'create_organization', 'delete_organization', 'set_active_organization', 'leave_organization']) { + expect(offered('admin', name), name).toBe(false); + } + }); + + it('a plain member is not offered it, and the door refuses the member', async () => { + expect(offered('member', 'rename_organization')).toBe(false); + const before = await readOrg(String(orgRow.id)); + const res = await runRename('member', { name: 'Renamed By Member' }); + expect(res.status, await res.clone().text()).toBe(403); + expect(((await res.json()) as { code?: string }).code).toBe('YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION'); + expect((await readOrg(String(orgRow.id))).name).toBe(before.name); + }); + + it('the rename lands: the name reads back changed and the slug is kept', async () => { + const before = await readOrg(String(orgRow.id)); + expect(before.slug, 'PREMISE: the default organization carries a slug').toBeTruthy(); + const res = await runRename('admin', { name: 'Acme Corporation' }); + expect(res.status, `organization/update returned ${res.status}: ${await res.clone().text()}`).toBe(200); + const after = await readOrg(String(orgRow.id)); + expect(after.name).toBe('Acme Corporation'); + expect(after.slug).toBe(before.slug); + }); +}); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 5b8ba535bc5..06dc23e9853 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -129,6 +129,11 @@ export const PUBLIC_AUTH_FEATURES = { // page's `record_header` — a `page:header` resolves ids against its own // object's actions only (ruling 6228318638, B′ on #24034). 'sys_organization.actions.invite_user', + // The name/logo-only rename door, at the record header and the row: + // `update_organization`'s endpoint and grades, without its `slug` param. + // On the capability, not multi-org: a `single` deployment's one + // organization has no other name door (ruling 6384629949 on #25347). + 'sys_organization.actions.rename_organization', ], notes: 'Org CAPABILITY gate, not multi-org (cloud ADR-0081 D1).', }, @@ -138,9 +143,6 @@ export const PUBLIC_AUTH_FEATURES = { gatedInputs: [ 'sys_organization.actions.create_organization', 'sys_organization.actions.update_organization', - // The name/logo-only rename door, at the record header and the row: - // `update_organization`'s endpoint and gate, without its `slug` param. - 'sys_organization.actions.rename_organization', 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization',