Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/25347-rename-organization-auth-feature.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions .changeset/25347-rename-organization-single.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
fields: {
name: {
label: "名前",
help: "組織自体の名前。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。"
help: "組織自体の名前。このレコード上部の「組織名を変更」で変更できます。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。"
},
slug: {
label: "スラッグ",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
fields: {
name: {
label: "名称",
help: "组织自身的名称。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。"
help: "组织自身的名称。可在此记录顶部用“重命名组织”修改。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。"
},
slug: {
label: "标识符",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')}`);
});
});

Expand Down
16 changes: 12 additions & 4 deletions packages/platform-objects/src/identity/sys-organization.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -299,16 +305,18 @@ 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,
readonly: true,
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',
}),

Expand Down
18 changes: 12 additions & 6 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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');
});
Expand Down
156 changes: 156 additions & 0 deletions packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
let orgRow: Record<string, unknown>;
const tokens = {} as Record<Principal, string>;
const sessionUser = {} as Record<Principal, Record<string, unknown>>;
const served = {} as Record<Principal, Map<string, ServedAction>>;

const readOrg = async (id: string): Promise<Record<string, unknown>> => {
const rows = await ql.find('sys_organization', { where: { id }, limit: 1 }, { context: SYSTEM_CTX });
return ((Array.isArray(rows) ? rows : rows?.records ?? [])[0] ?? {}) as Record<string, unknown>;
};

/** 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<string, unknown>) => {
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<string, unknown> = 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<any>('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<string, unknown> } }).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<string, unknown> }).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<Record<string, unknown>>;
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);
});
});
Loading
Loading