From 4a1577e1fe513d8bd8cbad3c5f3130d2ab59c386 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:33:06 +0000 Subject: [PATCH 1/6] wip(#25347): name and timezone homes --- .../translations/es-ES.objects.generated.ts | 5 ++-- .../translations/ja-JP.objects.generated.ts | 5 ++-- .../translations/zh-CN.objects.generated.ts | 5 ++-- .../src/identity/sys-organization.object.ts | 24 +++++++++++++++-- .../src/manifests/branding.manifest.ts | 27 ++++++++++++++----- .../src/manifests/localization.manifest.ts | 6 ++++- .../service-settings/src/translations/en.ts | 8 +++--- .../src/translations/es-ES.ts | 8 +++--- .../src/translations/ja-JP.ts | 8 +++--- .../src/translations/zh-CN.ts | 9 ++++--- 10 files changed, 77 insertions(+), 28 deletions(-) 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 0fa038b0623..5e2376b66e9 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 @@ -526,7 +526,8 @@ export const esESObjects: NonNullable = { description: "Organizaciones para agrupación multi-tenant", fields: { name: { - label: "Nombre" + label: "Nombre", + help: "El nombre de la organización, que se cambia con Renombrar organización. El nombre del producto que muestra la consola se define en Configuración → Marca." }, slug: { label: "Slug", @@ -545,7 +546,7 @@ export const esESObjects: NonNullable = { }, timezone: { label: "Zona horaria", - help: "Zona horaria IANA (p. ej. UTC, Asia/Shanghai) en la que se calculan los límites de fecha de esta organización: el valor raíz que hereda toda unidad de negocio sin zona propia. Sin definir significa UTC." + help: "Zona horaria IANA (p. ej. UTC, Asia/Shanghai), guardada como valor raíz para las unidades de negocio sin zona propia. Hoy no cambia ningún cálculo de fechas: las fórmulas, los informes y las fechas mostradas usan Configuración → Localización → Zona horaria predeterminada." }, parent_organization_id: { label: "Organización principal", 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 f90d59f4537..890d54357be 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 @@ -526,7 +526,8 @@ export const jaJPObjects: NonNullable = { description: "マルチテナントグループ化のための組織", fields: { name: { - label: "名前" + label: "名前", + help: "組織の名前。「組織名を変更」で変更します。コンソールに表示される製品名は 設定 → ブランディング で設定します。" }, slug: { label: "スラッグ", @@ -545,7 +546,7 @@ export const jaJPObjects: NonNullable = { }, timezone: { label: "タイムゾーン", - help: "この組織で日付の境界を計算する IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないすべてのビジネスユニットが継承するルートの既定値です。未設定の場合は UTC です。" + help: "IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないビジネスユニットのルートの既定値として保存されます。現在、日付の計算には影響しません。数式、レポート、表示される日付は 設定 → ローカリゼーション → 既定のタイムゾーン を使います。" }, parent_organization_id: { 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 ff21381525b..34ada307300 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 @@ -526,7 +526,8 @@ export const zhCNObjects: NonNullable = { description: "用于多租户分组的组织", fields: { name: { - label: "名称" + label: "名称", + help: "组织的名称,通过「重命名组织」修改。控制台显示的产品名称在 设置 → 品牌 中设置。" }, slug: { label: "标识符", @@ -545,7 +546,7 @@ export const zhCNObjects: NonNullable = { }, timezone: { label: "时区", - help: "该组织计算日期边界所用的 IANA 时区(如 UTC、Asia/Shanghai)——所有未自行设置时区的业务单元都继承的根默认值。未设置表示 UTC。" + help: "IANA 时区(如 UTC、Asia/Shanghai),作为未自行设置时区的业务单元的根默认值保存。它目前不会改变任何日期计算:公式、报表和显示的日期使用 设置 → 本地化 → 默认时区。" }, parent_organization_id: { label: "上级组织", diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 385395a3126..3a3a5778848 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -245,7 +245,16 @@ export const SysOrganization = ObjectSchema.create({ target: '/api/v1/auth/organization/update', recordIdParam: 'organizationId', bodyShape: { wrap: 'data' }, - requiresFeature: 'multiOrgEnabled', + // Gated on the organization CAPABILITY, not on `multiOrgEnabled` (#25347): + // a `single` deployment has one organization, and this is the only door + // that renames it — the generic edit form keeps `name` readonly (ADR-0092 + // D4) and `update_organization` stays multi-org-only for its slug. Without + // it a single-organization deployment could never change "Default + // Organization". Nothing here is multi-org: no slug is sent, so neither + // better-auth's slug-taken check nor `beforeUpdateOrganization`'s slug + // guard runs, and the default organization is found by its slug, which a + // rename keeps. + requiresFeature: 'organization', // The same door as `update_organization`, so the same grades. requiresMembershipReach: 'update_organization', successMessage: 'Organization renamed', @@ -297,12 +306,17 @@ export const SysOrganization = ObjectSchema.create({ // params are declared on the action, not read off these flags). The // engine's own static-`readonly` strip exempts system-context writers, so // better-auth's adapter (which stamps `isSystem`) still writes them. + // [#25347] The organization's one name, and its help says where it is + // changed (`rename_organization`) and what it is not: the product name the + // console shows is Settings → Branding's `workspace_name`, a separate home. name: Field.text({ label: 'Name', required: true, readonly: true, searchable: true, maxLength: 255, + description: + 'The organization\'s name, changed with Rename Organization. The product name the console shows is set in Settings → Branding.', group: 'Identity', }), @@ -370,6 +384,12 @@ export const SysOrganization = ObjectSchema.create({ // dates in today; how the two relate is the resolver's question, not this // column's, and this column does not read or write that setting. // + // [#25347] So the help says which one wins, in the reader's terms: this + // column changes no date calculation today, and Settings → Localization's + // Default timezone is the one in effect (its help says the same from the + // other side). The old help said date boundaries ARE computed in this + // zone, a claim no reader backed (AGENTS.md Prime Directive #10). + // // Owned by objectql, not better-auth: an ADR-0105 D7 extension field, // registered in plugin-auth's `MANAGED_EXTENSION_FIELDS` (whose collision // guard proves better-auth's organization schema owns no `timezone` at the @@ -389,7 +409,7 @@ export const SysOrganization = ObjectSchema.create({ maxLength: 64, valueDomain: 'iana_time_zone', description: - 'IANA time zone (e.g. UTC, Asia/Shanghai) date boundaries are computed in for this organization — the root default every business unit without a zone of its own inherits. Unset means UTC.', + 'IANA time zone (e.g. UTC, Asia/Shanghai), kept as the root default for business units without a zone of their own. It does not change any date calculation today: formulas, reports and displayed dates use Settings → Localization → Default timezone.', group: 'Configuration', }), diff --git a/packages/services/service-settings/src/manifests/branding.manifest.ts b/packages/services/service-settings/src/manifests/branding.manifest.ts index 6e63f4cce79..ff53a024fb6 100644 --- a/packages/services/service-settings/src/manifests/branding.manifest.ts +++ b/packages/services/service-settings/src/manifests/branding.manifest.ts @@ -24,10 +24,21 @@ import type { SettingsManifest } from '@objectstack/spec/system'; * * A value nobody saved (`source: 'default'`) is not served: the deployment's * own branding applies (`OS_PRODUCT_NAME`, `OS_LOGO_URL`, `OS_BRAND_COLOR`, or - * the host's `RuntimeConfigPlugin` options). That is why `accent_color` - * declares no `default`: a default here would be shown on the settings page as - * a colour the console never uses. `workspace_name` keeps its `'ObjectStack'` - * default, which is also the name the auth emails fall back to. + * the host's `RuntimeConfigPlugin` options; `OS_APP_NAME` for the auth + * readers). That is why no key here declares a `default`: a default would be + * shown on the settings page as a value the console never uses. The name's + * `'ObjectStack'` default did exactly that (#25347): an unsaved page read + * "ObjectStack" while the console read "ObjectOS". So `workspace_name` is not + * `required` either: an empty value means "use the deployment's name", and + * both readers already treat an empty value as unset. + * + * ## The product's name, not the organization's + * + * `workspace_name` names the PRODUCT the deployment shows. The organization's + * own name is `sys_organization.name`, renamed with the Organization record's + * Rename Organization action (#25347). Two names, two homes, and each label + * says which one it is: the label here is "Product name", never "Organization + * name", and its help points at the record. * * ## Single posture only * @@ -63,11 +74,13 @@ export const brandingSettingsManifest: SettingsManifest = { order: 5, specifiers: [ { type: 'group', id: 'identity', label: 'Identity', required: false }, - { type: 'text', key: 'workspace_name', label: 'Organization name', required: true, - default: 'ObjectStack', minLength: 1, maxLength: 60, + { type: 'text', key: 'workspace_name', label: 'Product name', required: false, + minLength: 1, maxLength: 60, description: 'The product name in the console, on the sign-in page and in the browser title, ' + - 'and in sign-in emails, the authenticator app and MCP sign-in.' }, + 'and in sign-in emails, the authenticator app and MCP sign-in. ' + + 'Leave empty to use the name this deployment was set up with. ' + + 'This is not the organization\'s own name, which is changed on the Organization record (Rename Organization).' }, { type: 'group', id: 'appearance', label: 'Appearance', required: false }, { type: 'color', key: 'accent_color', label: 'Accent colour', required: false, diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts index e9af7ed9ade..170aa12095d 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -49,7 +49,11 @@ export const localizationSettingsManifest: SettingsManifest = { // Localization and the first-run timezone prompt), so it names effects, // not formula functions; `localization-timezone-help.pin.test.ts` holds // every served locale to that. - description: 'Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris).', + // [#25347] The second sentence says which timezone wins: this key is the + // organization's zone in effect (tenant scope, one row per organization), + // and `sys_organization.timezone`, which no reader consults, does not + // change it. That field's help says the same from its side. + description: 'Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris). This is the timezone in effect for the organization: the Timezone field on the Organization record does not change it.', // The description has always promised the IANA domain; since #5712 the // declaration matches it: any valid IANA zone is accepted on the write // and env doors, and the curated options below are a UI convenience diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 803b9726019..f80aa49543b 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -73,10 +73,12 @@ export const en: PlatformTranslationData = { }, keys: { workspace_name: { - label: 'Organization name', + label: 'Product name', help: 'The product name in the console, on the sign-in page and in the browser title, ' + - 'and in sign-in emails, the authenticator app and MCP sign-in.', + 'and in sign-in emails, the authenticator app and MCP sign-in. ' + + 'Leave empty to use the name this deployment was set up with. ' + + 'This is not the organization\'s own name, which is changed on the Organization record (Rename Organization).', }, accent_color: { label: 'Accent colour', @@ -122,7 +124,7 @@ export const en: PlatformTranslationData = { finance: { title: 'Finance' }, }, keys: { - timezone: { label: 'Default timezone', help: 'Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris).' }, + timezone: { label: 'Default timezone', help: 'Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris). This is the timezone in effect for the organization: the Timezone field on the Organization record does not change it.' }, locale: { label: 'Default language', help: 'BCP-47 locale for message catalogs and number/date formatting.' }, default_country: { label: 'Default country', help: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).' }, currency: { label: 'Default currency' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 36bd4cf11e5..2149788455d 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -69,10 +69,12 @@ export const esES: PlatformTranslationData = { }, keys: { workspace_name: { - label: 'Nombre de la organización', + label: 'Nombre del producto', help: 'El nombre del producto en la consola, en la página de inicio de sesión y en el título del navegador, ' + - 'y en los correos de inicio de sesión, la aplicación de autenticación y el inicio de sesión de MCP.', + 'y en los correos de inicio de sesión, la aplicación de autenticación y el inicio de sesión de MCP. ' + + 'Déjelo vacío para usar el nombre con el que se configuró esta implantación. ' + + 'No es el nombre de la organización, que se cambia en el registro de la organización (Renombrar organización).', }, accent_color: { label: 'Color de acento', @@ -327,7 +329,7 @@ export const esES: PlatformTranslationData = { finance: { title: 'Finanzas' }, }, keys: { - timezone: { label: 'Zona horaria predeterminada', help: 'Determina qué significa «hoy» en las fórmulas, cómo agrupan las fechas los informes y cómo se muestran las fechas y horas. Acepta cualquier nombre de zona horaria IANA (p. ej. Europe/Madrid).' }, + timezone: { label: 'Zona horaria predeterminada', help: 'Determina qué significa «hoy» en las fórmulas, cómo agrupan las fechas los informes y cómo se muestran las fechas y horas. Acepta cualquier nombre de zona horaria IANA (p. ej. Europe/Madrid). Es la zona horaria vigente para la organización: el campo Zona horaria del registro de la organización no la cambia.' }, locale: { label: 'Idioma predeterminado', help: 'Configuración regional BCP-47 para los catálogos de mensajes y el formato de números/fechas.' }, default_country: { label: 'País predeterminado', help: 'Código ISO 3166-1 alfa-2 (p. ej. US, GB, CN). Se usa para los valores por defecto de dirección y teléfono.' }, currency: { label: 'Moneda predeterminada', help: 'Código ISO 4217 aplicado cuando un campo de moneda no indica la suya.' }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 347b8aff169..c76dd2c9fed 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -69,10 +69,12 @@ export const jaJP: PlatformTranslationData = { }, keys: { workspace_name: { - label: '組織名', + label: '製品名', help: 'コンソール、サインインページ、ブラウザのタイトルに表示される製品名。' + - 'サインインメール、認証アプリ、MCP サインインでも使われます。', + 'サインインメール、認証アプリ、MCP サインインでも使われます。' + + '空欄にすると、このデプロイで設定された名前が使われます。' + + 'これは組織自体の名前ではありません。組織名は組織レコード(組織名を変更)で変更します。', }, accent_color: { label: 'アクセントカラー', @@ -327,7 +329,7 @@ export const jaJP: PlatformTranslationData = { finance: { title: '財務' }, }, keys: { - timezone: { label: '既定のタイムゾーン', help: '数式での「今日」がどの日か、レポートでの日付のまとめ方、日付と時刻の表示方法を決めます。任意の IANA タイムゾーン名(例:Asia/Tokyo)を指定できます。' }, + timezone: { label: '既定のタイムゾーン', help: '数式での「今日」がどの日か、レポートでの日付のまとめ方、日付と時刻の表示方法を決めます。任意の IANA タイムゾーン名(例:Asia/Tokyo)を指定できます。これが組織で実際に使われるタイムゾーンです。組織レコードの「タイムゾーン」フィールドではこれは変わりません。' }, locale: { label: '既定の言語', help: 'メッセージカタログと数値/日付フォーマットに使う BCP-47 ロケール。' }, default_country: { label: '既定の国', help: 'ISO 3166-1 alpha-2 コード(例:US、GB、CN)。住所と電話の既定に使用します。' }, currency: { label: '既定の通貨', help: '通貨フィールドが独自の通貨を指定しない場合に適用される ISO 4217 コード。' }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index f0537477cee..81472064c9f 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -107,8 +107,11 @@ export const zhCN: PlatformTranslationData = { }, keys: { workspace_name: { - label: '组织名称', - help: '控制台、登录页与浏览器标题中的产品名,也用于登录邮件、身份验证器应用与 MCP 登录。', + label: '产品名称', + help: + '控制台、登录页与浏览器标题中的产品名,也用于登录邮件、身份验证器应用与 MCP 登录。' + + '留空则使用本部署设定的名称。' + + '这不是组织自身的名称,组织名称在组织记录上修改(重命名组织)。', }, accent_color: { label: '主题色', @@ -156,7 +159,7 @@ export const zhCN: PlatformTranslationData = { keys: { timezone: { label: '默认时区', - help: '决定公式中的"今天"是哪一天、报表如何按日期分组,以及日期和时间如何显示。可填写任意 IANA 时区名称(如 Asia/Shanghai)。', + help: '决定公式中的"今天"是哪一天、报表如何按日期分组,以及日期和时间如何显示。可填写任意 IANA 时区名称(如 Asia/Shanghai)。这是组织实际生效的时区:组织记录上的「时区」字段不会改变它。', options: { UTC: 'UTC', 'America/Los_Angeles': '(UTC−08/−07) 洛杉矶', From 6fd3df8051a6b5ab0554e6dd5c6d4bc58a4c0412 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:38:57 +0000 Subject: [PATCH 2/6] wip(#25347): pins, changesets, regenerated en bundle --- .changeset/25347-branding-product-name.md | 15 ++ .../25347-organization-rename-single.md | 12 ++ .../apps/translations/en.objects.generated.ts | 5 +- .../sys-organization-rename-action.test.ts | 39 ++++- .../src/platform-objects.test.ts | 8 +- ...organization-rename-single.dogfood.test.ts | 156 ++++++++++++++++++ .../src/manifests/branding.manifest.test.ts | 32 ++++ 7 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 .changeset/25347-branding-product-name.md create mode 100644 .changeset/25347-organization-rename-single.md create mode 100644 packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts diff --git a/.changeset/25347-branding-product-name.md b/.changeset/25347-branding-product-name.md new file mode 100644 index 00000000000..867d6166a7d --- /dev/null +++ b/.changeset/25347-branding-product-name.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-settings': patch +--- + +fix(service-settings): Settings → Branding's name is labelled the product name, shows no default the console never uses, and Localization's timezone says it is the one in effect + +Clause-②: yes (widening) +Metadata-protocol: none + +One organization showed three names: Branding's "Organization name" read `ObjectStack`, the organization record read `Default Organization`, and the console read `ObjectOS`. Branding's key names the PRODUCT the deployment shows, and the organization's own name lives on its record, so each now says which one it is. + +- **`branding.workspace_name`.** The label is "Product name" (in all four bundles), not "Organization name". Its help says an empty value uses the name the deployment was set up with, and that the organization's own name is changed on the Organization record (Rename Organization). +- **No default.** The `'ObjectStack'` default is gone. Neither reader ever served it: the runtime config and the auth readers use only a value somebody saved, and otherwise the deployment's own name (`OS_PRODUCT_NAME`, `OS_APP_NAME`, or the host options). So an unsaved page showed a name nothing used. An unsaved page now shows the field empty, and `GET /api/settings/branding` answers `workspace_name` with no value and `source: 'default'`. +- **Not required.** A key with no default stays optional, as `accent_color` already is, so the console can save the page without one. This widens the write: an empty `workspace_name` is accepted (it was refused as required) and means "use the deployment's name", which is how both readers already treat an empty value. Nothing served changes. +- **`localization.timezone`.** Its help adds that it is the timezone in effect for the organization, and that the Timezone field on the Organization record does not change it (in all four bundles). diff --git a/.changeset/25347-organization-rename-single.md b/.changeset/25347-organization-rename-single.md new file mode 100644 index 00000000000..d7cf3ae8c1f --- /dev/null +++ b/.changeset/25347-organization-rename-single.md @@ -0,0 +1,12 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): a single-organization deployment can rename its organization, and the organization record says which timezone wins + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`sys_organization.rename_organization`** (name and logo, no slug) is gated on the organization capability (`features.organization`) instead of `features.multiOrgEnabled`, still for the grades `organization/update` admits (owner, admin). Under `single` it was hidden, and the generic edit form keeps `name` readonly, so the one organization could never be renamed from "Default Organization". A rename sends no slug, so the slug the default-organization bootstrap finds the row by is kept. `update_organization`, the slug door, stays multi-organization only. +- **`sys_organization.name`'s help** says it is changed with Rename Organization, and that the product name the console shows is set in Settings → Branding. +- **`sys_organization.timezone`'s help** no longer says date boundaries are computed in it: nothing on the platform reads the column. It now says the value is kept as the root default for business units, changes no date calculation today, and that formulas, reports and displayed dates use Settings → Localization → Default timezone. 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 d970b1c354f..0f5a1418979 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -526,7 +526,8 @@ export const enObjects: NonNullable = { description: "Organizations for multi-tenant grouping", fields: { name: { - label: "Name" + label: "Name", + help: "The organization's name, changed with Rename Organization. The product name the console shows is set in Settings → Branding." }, slug: { label: "Slug", @@ -545,7 +546,7 @@ export const enObjects: NonNullable = { }, timezone: { label: "Timezone", - help: "IANA time zone (e.g. UTC, Asia/Shanghai) date boundaries are computed in for this organization — the root default every business unit without a zone of its own inherits. Unset means UTC." + help: "IANA time zone (e.g. UTC, Asia/Shanghai), kept as the root default for business units without a zone of their own. It does not change any date calculation today: formulas, reports and displayed dates use Settings → Localization → Default timezone." }, parent_organization_id: { label: "Parent Organization", 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..a02a0e158cd 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 @@ -12,9 +12,11 @@ // What this file pins, from source: // - 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); +// `organization/update` exactly as `update_organization` does, it is +// gated on the grades that reach `organization/update` (owner, admin) like +// it, and on the organization CAPABILITY, not on `multiOrgEnabled`: a +// `single` deployment renames its one organization through this door +// (#25347), while `update_organization`, the slug door, stays multi-org; // - 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 +104,13 @@ 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, and on the organization capability, so single offers it', () => { + // [#25347] Under `single` (`multiOrgEnabled: false`) this is the only door + // that renames the deployment's one organization: the generic edit form + // keeps `name` readonly, and `update_organization` stays multi-org-only. + const reach = `(${membershipReachPredicate('update_organization')})`; + expect(action('rename_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('organization')}`); + expect(action('update_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('multiOrgEnabled')}`); }); }); @@ -152,3 +157,23 @@ describe('sys_organization: the open-source slug door is unchanged (control)', ( expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']); }); }); + +describe('sys_organization: each name and timezone field says where its value is set (#25347)', () => { + const field = (name: string) => + (SysOrganization.fields as Record)[name]; + + it("the organization's name names its door and points at Branding for the product name", () => { + expect(field('name').readonly).toBe(true); + expect(field('name').description).toMatch(/changed with Rename Organization/); + expect(field('name').description).toMatch(/Settings → Branding/); + }); + + it('the timezone says it changes no date calculation and that Localization\'s Default timezone wins', () => { + // Nothing in the platform reads this column (see its declaration); the zone + // in effect is the settings door's `localization.timezone`. + const help = field('timezone').description ?? ''; + expect(help).toMatch(/does not change any date calculation/); + expect(help).toMatch(/Settings → Localization → Default timezone/); + expect(help).not.toMatch(/are computed in/); + }); +}); diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index b60f2e1ba07..128d49e583f 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 rename door is the organization's, under every posture: + // `single` has one organization and this is the only way to rename it. + ['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], @@ -770,7 +772,9 @@ describe('feature-gate lowering matrix (#2874)', () => { 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, , , gate]) => exported === 'SysOrganization' && gate.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/services/service-settings/src/manifests/branding.manifest.test.ts b/packages/services/service-settings/src/manifests/branding.manifest.test.ts index 95b3cada4cc..d3eecf9b72c 100644 --- a/packages/services/service-settings/src/manifests/branding.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/branding.manifest.test.ts @@ -13,6 +13,9 @@ const specs = brandingSettingsManifest.specifiers as Array<{ type: string; key?: string; id?: string; + label?: string; + description?: string; + required?: boolean; default?: unknown; }>; const byKey = (k: string) => specs.find((s) => s.key === k); @@ -54,4 +57,33 @@ describe('brandingSettingsManifest', () => { expect(byKey('accent_color')!.default).toBeUndefined(); expect(byKey('logo_url')!.default).toBeUndefined(); }); + + // [#25347] One organization showed three names: Branding's unsaved + // 'ObjectStack' default, the organization record's "Default Organization", + // and the console's 'ObjectOS'. The first was a value nobody serves, and the + // key's label claimed the organization's name for the product's. + describe('workspace_name is the product name, with no default of its own (#25347)', () => { + const name = () => byKey('workspace_name')!; + + it('declares no default: an unsaved page shows no name the console never shows', () => { + // Both readers (runtime config and plugin-auth) serve only a value somebody + // SET; a manifest default was shown on the page and used nowhere. + expect(name().default).toBeUndefined(); + expect(SettingsManifestSchema.parse(brandingSettingsManifest).specifiers + .find((s) => s.key === 'workspace_name')!.default).toBeUndefined(); + }); + + it('is not required: empty means the deployment\'s own name, so the page can save without one', () => { + // A required key with no default blocks every save of the namespace in + // the console's pre-save check until somebody types a name. + expect(name().required).toBe(false); + }); + + it('is labelled the product name, and its help points at the organization record for the organization\'s name', () => { + expect(name().label).toBe('Product name'); + expect(name().description).toMatch(/not the organization's own name/); + expect(name().description).toMatch(/Organization record \(Rename Organization\)/); + expect(name().description).toMatch(/Leave empty/); + }); + }); }); From 3f4e0736d178995c5053bbda6e0a1567380f47f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:42:43 +0000 Subject: [PATCH 3/6] wip(#25347): keep the rename gate for a spec-lane follow-up; help names no door --- .changeset/25347-branding-product-name.md | 4 +- .../25347-organization-name-timezone-help.md | 13 ++ .../25347-organization-rename-single.md | 12 -- .../apps/translations/en.objects.generated.ts | 2 +- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- ...ys-organization-name-timezone-help.test.ts | 33 ++++ .../sys-organization-rename-action.test.ts | 39 +---- .../src/identity/sys-organization.object.ts | 20 +-- .../src/platform-objects.test.ts | 8 +- ...organization-rename-single.dogfood.test.ts | 156 ------------------ .../src/manifests/branding.manifest.test.ts | 2 +- .../src/manifests/branding.manifest.ts | 10 +- .../src/settings-service.test.ts | 16 +- .../service-settings/src/translations/en.ts | 2 +- .../src/translations/es-ES.ts | 2 +- .../src/translations/ja-JP.ts | 2 +- .../src/translations/zh-CN.ts | 2 +- 19 files changed, 91 insertions(+), 238 deletions(-) create mode 100644 .changeset/25347-organization-name-timezone-help.md delete mode 100644 .changeset/25347-organization-rename-single.md create mode 100644 packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts delete mode 100644 packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts diff --git a/.changeset/25347-branding-product-name.md b/.changeset/25347-branding-product-name.md index 867d6166a7d..b07485b2bdf 100644 --- a/.changeset/25347-branding-product-name.md +++ b/.changeset/25347-branding-product-name.md @@ -9,7 +9,7 @@ Metadata-protocol: none One organization showed three names: Branding's "Organization name" read `ObjectStack`, the organization record read `Default Organization`, and the console read `ObjectOS`. Branding's key names the PRODUCT the deployment shows, and the organization's own name lives on its record, so each now says which one it is. -- **`branding.workspace_name`.** The label is "Product name" (in all four bundles), not "Organization name". Its help says an empty value uses the name the deployment was set up with, and that the organization's own name is changed on the Organization record (Rename Organization). -- **No default.** The `'ObjectStack'` default is gone. Neither reader ever served it: the runtime config and the auth readers use only a value somebody saved, and otherwise the deployment's own name (`OS_PRODUCT_NAME`, `OS_APP_NAME`, or the host options). So an unsaved page showed a name nothing used. An unsaved page now shows the field empty, and `GET /api/settings/branding` answers `workspace_name` with no value and `source: 'default'`. +- **`branding.workspace_name`.** The label is "Product name" (in all four bundles), not "Organization name". Its help says an empty value uses the name the deployment was set up with, and that the organization's own name is the Name on the Organization record. +- **No default.** The `'ObjectStack'` default is gone. Neither reader ever served it: the runtime config and the auth readers use only a value somebody saved, and otherwise the deployment's own name (`OS_PRODUCT_NAME`, `OS_APP_NAME`, or the host options). So an unsaved page showed a name nothing used. An unsaved page now shows the field empty, and `GET /api/settings/branding` answers `workspace_name` as `value: null`, `source: 'default'`. - **Not required.** A key with no default stays optional, as `accent_color` already is, so the console can save the page without one. This widens the write: an empty `workspace_name` is accepted (it was refused as required) and means "use the deployment's name", which is how both readers already treat an empty value. Nothing served changes. - **`localization.timezone`.** Its help adds that it is the timezone in effect for the organization, and that the Timezone field on the Organization record does not change it (in all four bundles). diff --git a/.changeset/25347-organization-name-timezone-help.md b/.changeset/25347-organization-name-timezone-help.md new file mode 100644 index 00000000000..6aada7cc47a --- /dev/null +++ b/.changeset/25347-organization-name-timezone-help.md @@ -0,0 +1,13 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): the organization record says its name is not the product name, and that Localization's timezone is the one in effect + +Clause-②: no +Metadata-protocol: none + +- **`sys_organization.name`'s help** says it is the organization's own name, and that the product name the console shows is a different setting, set in Settings → Branding. +- **`sys_organization.timezone`'s help** no longer says date boundaries are computed in it: nothing on the platform reads the column. It now says the value is kept as the root default for business units, changes no date calculation today, and that formulas, reports and displayed dates use Settings → Localization → Default timezone. + +Both in all four bundles. Nothing stored or served changes meaning. diff --git a/.changeset/25347-organization-rename-single.md b/.changeset/25347-organization-rename-single.md deleted file mode 100644 index d7cf3ae8c1f..00000000000 --- a/.changeset/25347-organization-rename-single.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix(platform-objects): a single-organization deployment can rename its organization, and the organization record says which timezone wins - -Clause-②: yes (widening) -Metadata-protocol: none - -- **`sys_organization.rename_organization`** (name and logo, no slug) is gated on the organization capability (`features.organization`) instead of `features.multiOrgEnabled`, still for the grades `organization/update` admits (owner, admin). Under `single` it was hidden, and the generic edit form keeps `name` readonly, so the one organization could never be renamed from "Default Organization". A rename sends no slug, so the slug the default-organization bootstrap finds the row by is kept. `update_organization`, the slug door, stays multi-organization only. -- **`sys_organization.name`'s help** says it is changed with Rename Organization, and that the product name the console shows is set in Settings → Branding. -- **`sys_organization.timezone`'s help** no longer says date boundaries are computed in it: nothing on the platform reads the column. It now says the value is kept as the root default for business units, changes no date calculation today, and that formulas, reports and displayed dates use Settings → Localization → Default timezone. 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 0f5a1418979..27ce613a323 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -527,7 +527,7 @@ export const enObjects: NonNullable = { fields: { name: { label: "Name", - help: "The organization's name, changed with Rename Organization. The product name the console shows is set in Settings → Branding." + help: "The organization's own name. 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 5e2376b66e9..311c976f0a2 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 @@ -527,7 +527,7 @@ export const esESObjects: NonNullable = { fields: { name: { label: "Nombre", - help: "El nombre de la organización, que se cambia con Renombrar organización. El nombre del producto que muestra la consola se define en Configuración → Marca." + 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." }, 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 890d54357be..a89369c8b9f 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 @@ -527,7 +527,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 34ada307300..08ff7f7dfec 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 @@ -527,7 +527,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 new file mode 100644 index 00000000000..ce58aee41b4 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// sys_organization's `name` and `timezone` help say which value is which +// (#25347). One organization showed three names and two timezones, with no +// rule shown: +// +// - `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. +// - `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. +// Localization's own help says the same from its side +// (`localization-timezone-help.pin.test.ts` in service-settings serves it). +import { describe, expect, it } from 'vitest'; +import { SysOrganization } from './sys-organization.object.js'; + +const field = (name: string) => + (SysOrganization.fields as Record)[name]; + +describe('sys_organization: the name and timezone fields say which value is which (#25347)', () => { + it("the organization's name points at Branding for the product name, a different setting", () => { + expect(field('name').readonly).toBe(true); + expect(field('name').description).toMatch(/different setting, set in Settings → Branding/); + }); + + 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/); + expect(help).toMatch(/Settings → Localization → Default timezone/); + expect(help).not.toMatch(/are computed in/); + }); +}); 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 a02a0e158cd..5537261e045 100644 --- a/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts +++ b/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts @@ -12,11 +12,9 @@ // What this file pins, from source: // - 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, it is -// gated on the grades that reach `organization/update` (owner, admin) like -// it, and on the organization CAPABILITY, not on `multiOrgEnabled`: a -// `single` deployment renames its one organization through this door -// (#25347), while `update_organization`, the slug door, stays multi-org; +// `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); // - 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 @@ -104,13 +102,10 @@ 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, like update_organization, and on the organization capability, so single offers it', () => { - // [#25347] Under `single` (`multiOrgEnabled: false`) this is the only door - // that renames the deployment's one organization: the generic edit form - // keeps `name` readonly, and `update_organization` stays multi-org-only. - const reach = `(${membershipReachPredicate('update_organization')})`; - expect(action('rename_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('organization')}`); - expect(action('update_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('multiOrgEnabled')}`); + 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); }); }); @@ -157,23 +152,3 @@ describe('sys_organization: the open-source slug door is unchanged (control)', ( expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']); }); }); - -describe('sys_organization: each name and timezone field says where its value is set (#25347)', () => { - const field = (name: string) => - (SysOrganization.fields as Record)[name]; - - it("the organization's name names its door and points at Branding for the product name", () => { - expect(field('name').readonly).toBe(true); - expect(field('name').description).toMatch(/changed with Rename Organization/); - expect(field('name').description).toMatch(/Settings → Branding/); - }); - - it('the timezone says it changes no date calculation and that Localization\'s Default timezone wins', () => { - // Nothing in the platform reads this column (see its declaration); the zone - // in effect is the settings door's `localization.timezone`. - const help = field('timezone').description ?? ''; - expect(help).toMatch(/does not change any date calculation/); - expect(help).toMatch(/Settings → Localization → Default timezone/); - expect(help).not.toMatch(/are computed in/); - }); -}); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 3a3a5778848..358dfbfa731 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -245,16 +245,7 @@ export const SysOrganization = ObjectSchema.create({ target: '/api/v1/auth/organization/update', recordIdParam: 'organizationId', bodyShape: { wrap: 'data' }, - // Gated on the organization CAPABILITY, not on `multiOrgEnabled` (#25347): - // a `single` deployment has one organization, and this is the only door - // that renames it — the generic edit form keeps `name` readonly (ADR-0092 - // D4) and `update_organization` stays multi-org-only for its slug. Without - // it a single-organization deployment could never change "Default - // Organization". Nothing here is multi-org: no slug is sent, so neither - // better-auth's slug-taken check nor `beforeUpdateOrganization`'s slug - // guard runs, and the default organization is found by its slug, which a - // rename keeps. - requiresFeature: 'organization', + requiresFeature: 'multiOrgEnabled', // The same door as `update_organization`, so the same grades. requiresMembershipReach: 'update_organization', successMessage: 'Organization renamed', @@ -306,9 +297,10 @@ export const SysOrganization = ObjectSchema.create({ // params are declared on the action, not read off these flags). The // engine's own static-`readonly` strip exempts system-context writers, so // better-auth's adapter (which stamps `isSystem`) still writes them. - // [#25347] The organization's one name, and its help says where it is - // changed (`rename_organization`) and what it is not: the product name the - // console shows is Settings → Branding's `workspace_name`, a separate home. + // [#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. name: Field.text({ label: 'Name', required: true, @@ -316,7 +308,7 @@ export const SysOrganization = ObjectSchema.create({ searchable: true, maxLength: 255, description: - 'The organization\'s name, changed with Rename Organization. The product name the console shows is set in Settings → Branding.', + 'The organization\'s own name. 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 128d49e583f..b60f2e1ba07 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -699,9 +699,7 @@ 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}`], - // [#25347] The rename door is the organization's, under every posture: - // `single` has one organization and this is the only way to rename it. - ['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${ORG}`], + ['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${MULTI_ORG}`], ['SysOrganization', SysOrganization, 'delete_organization', `(${OWNER}) && ${MULTI_ORG}`], ['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG], @@ -772,9 +770,7 @@ describe('feature-gate lowering matrix (#2874)', () => { 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, , , gate]) => exported === 'SysOrganization' && gate.includes(MULTI_ORG)) - .map(([, , name]) => name); + const pinned = rows.filter(([exported]) => exported === 'SysOrganization').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 deleted file mode 100644 index 22d79349202..00000000000 --- a/packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -// 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/services/service-settings/src/manifests/branding.manifest.test.ts b/packages/services/service-settings/src/manifests/branding.manifest.test.ts index d3eecf9b72c..f540c651458 100644 --- a/packages/services/service-settings/src/manifests/branding.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/branding.manifest.test.ts @@ -82,7 +82,7 @@ describe('brandingSettingsManifest', () => { it('is labelled the product name, and its help points at the organization record for the organization\'s name', () => { expect(name().label).toBe('Product name'); expect(name().description).toMatch(/not the organization's own name/); - expect(name().description).toMatch(/Organization record \(Rename Organization\)/); + expect(name().description).toMatch(/the Name on the Organization record/); expect(name().description).toMatch(/Leave empty/); }); }); diff --git a/packages/services/service-settings/src/manifests/branding.manifest.ts b/packages/services/service-settings/src/manifests/branding.manifest.ts index ff53a024fb6..424409fbe54 100644 --- a/packages/services/service-settings/src/manifests/branding.manifest.ts +++ b/packages/services/service-settings/src/manifests/branding.manifest.ts @@ -35,10 +35,10 @@ import type { SettingsManifest } from '@objectstack/spec/system'; * ## The product's name, not the organization's * * `workspace_name` names the PRODUCT the deployment shows. The organization's - * own name is `sys_organization.name`, renamed with the Organization record's - * Rename Organization action (#25347). Two names, two homes, and each label - * says which one it is: the label here is "Product name", never "Organization - * name", and its help points at the record. + * own name is `sys_organization.name`, the Name on the Organization record + * (#25347). Two names, two homes, and each label says which one it is: the + * label here is "Product name", never "Organization name", and its help points + * at the record. * * ## Single posture only * @@ -80,7 +80,7 @@ export const brandingSettingsManifest: SettingsManifest = { 'The product name in the console, on the sign-in page and in the browser title, ' + 'and in sign-in emails, the authenticator app and MCP sign-in. ' + 'Leave empty to use the name this deployment was set up with. ' + - 'This is not the organization\'s own name, which is changed on the Organization record (Rename Organization).' }, + 'This is not the organization\'s own name, which is the Name on the Organization record.' }, { type: 'group', id: 'appearance', label: 'Appearance', required: false }, { type: 'color', key: 'accent_color', label: 'Accent colour', required: false, diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index e82e515da6e..c76ee0849af 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -52,11 +52,22 @@ describe('SettingsService — registry', () => { describe('SettingsService — resolver precedence', () => { it('returns default when nothing set', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + const r = await svc.get('localization', 'timezone'); + expect(r.source).toBe('default'); + expect(r.value).toBe('UTC'); + expect(r.locked).toBe(false); + }); + + it('returns no value, as the default, for a key that declares none', async () => { + // [#25347] `branding.workspace_name` declares no default: an unsaved name + // is the deployment's own, which this service does not know. const svc = new SettingsService({ env: {} }); svc.registerManifest(brandingSettingsManifest); const r = await svc.get('branding', 'workspace_name'); expect(r.source).toBe('default'); - expect(r.value).toBe('ObjectStack'); + expect(r.value).toBeNull(); expect(r.locked).toBe(false); }); @@ -344,7 +355,8 @@ describe('SettingsService — resetNamespace / built-in reset action', () => { expect(cleared).toBe(1); const r = await svc.get('branding', 'workspace_name'); expect(r.source).toBe('default'); - expect(r.value).toBe('ObjectStack'); + // No manifest default (#25347): reset leaves the key unset. + expect(r.value).toBeNull(); }); it('leaves env-locked keys untouched', async () => { diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index f80aa49543b..6d425eedf93 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -78,7 +78,7 @@ export const en: PlatformTranslationData = { 'The product name in the console, on the sign-in page and in the browser title, ' + 'and in sign-in emails, the authenticator app and MCP sign-in. ' + 'Leave empty to use the name this deployment was set up with. ' + - 'This is not the organization\'s own name, which is changed on the Organization record (Rename Organization).', + 'This is not the organization\'s own name, which is the Name on the Organization record.', }, accent_color: { label: 'Accent colour', diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 2149788455d..d226a1f83e7 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -74,7 +74,7 @@ export const esES: PlatformTranslationData = { 'El nombre del producto en la consola, en la página de inicio de sesión y en el título del navegador, ' + 'y en los correos de inicio de sesión, la aplicación de autenticación y el inicio de sesión de MCP. ' + 'Déjelo vacío para usar el nombre con el que se configuró esta implantación. ' + - 'No es el nombre de la organización, que se cambia en el registro de la organización (Renombrar organización).', + 'No es el nombre propio de la organización, que es el Nombre del registro de la organización.', }, accent_color: { label: 'Color de acento', diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index c76dd2c9fed..cd1b50017d3 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -74,7 +74,7 @@ export const jaJP: PlatformTranslationData = { 'コンソール、サインインページ、ブラウザのタイトルに表示される製品名。' + 'サインインメール、認証アプリ、MCP サインインでも使われます。' + '空欄にすると、このデプロイで設定された名前が使われます。' + - 'これは組織自体の名前ではありません。組織名は組織レコード(組織名を変更)で変更します。', + 'これは組織自体の名前ではありません。組織自体の名前は組織レコードの「名前」です。', }, accent_color: { label: 'アクセントカラー', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 81472064c9f..7bd43be08bb 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -111,7 +111,7 @@ export const zhCN: PlatformTranslationData = { help: '控制台、登录页与浏览器标题中的产品名,也用于登录邮件、身份验证器应用与 MCP 登录。' + '留空则使用本部署设定的名称。' + - '这不是组织自身的名称,组织名称在组织记录上修改(重命名组织)。', + '这不是组织自身的名称,组织自身的名称是组织记录上的「名称」。', }, accent_color: { label: '主题色', From 24e4f696bce100f38beee736d4e049c11effe704 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:04:31 +0000 Subject: [PATCH 4/6] test(#25347): pin Localization's timezone help naming the zone in effect Claude-Session: https://claude.ai/code/session_01JJZRJcjTF4oBQK7333oAnR Co-authored-by: Claude --- .../src/manifests/localization.manifest.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/services/service-settings/src/manifests/localization.manifest.test.ts b/packages/services/service-settings/src/manifests/localization.manifest.test.ts index 19e961b0645..a8f7293e8c0 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.test.ts @@ -86,4 +86,12 @@ describe('localizationSettingsManifest', () => { } expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).not.toContain('formats'); }); + + it('the timezone help says it is the zone in effect, and the organization record\'s does not change it (#25347)', () => { + // Two timezones, no rule shown: `sys_organization.timezone` has no reader, + // so this key is the one every date calculation uses, and its help says so. + const tz = (localizationSettingsManifest.specifiers as any[]).find((s) => s.key === 'timezone'); + expect(tz.description).toMatch(/the timezone in effect for the organization/); + expect(tz.description).toMatch(/the Timezone field on the Organization record does not change it/); + }); }); From 65430cac33cde0315748ea32fbc9e8e636b395f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:05:29 +0000 Subject: [PATCH 5/6] =?UTF-8?q?chore(#25347):=20changeset=20Clause-?= =?UTF-8?q?=E2=91=A1=20line=20as=20claimed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JJZRJcjTF4oBQK7333oAnR Co-authored-by: Claude --- .changeset/25347-branding-product-name.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25347-branding-product-name.md b/.changeset/25347-branding-product-name.md index b07485b2bdf..f37c2d1ae40 100644 --- a/.changeset/25347-branding-product-name.md +++ b/.changeset/25347-branding-product-name.md @@ -4,7 +4,7 @@ fix(service-settings): Settings → Branding's name is labelled the product name, shows no default the console never uses, and Localization's timezone says it is the one in effect -Clause-②: yes (widening) +Clause-②: yes Metadata-protocol: none One organization showed three names: Branding's "Organization name" read `ObjectStack`, the organization record read `Default Organization`, and the console read `ObjectOS`. Branding's key names the PRODUCT the deployment shows, and the organization's own name lives on its record, so each now says which one it is. From 35b63d89a16afd5880727c422167732bb540164d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:08:46 +0000 Subject: [PATCH 6/6] docs(#25347): environment-variables says Branding's product name declares no default Claude-Session: https://claude.ai/code/session_01JJZRJcjTF4oBQK7333oAnR Co-authored-by: Claude --- content/docs/deployment/environment-variables.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 351c3cbc0ac..d19de0c4441 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -147,7 +147,7 @@ The console's product name, logo, favicon and colours are served by `RuntimeConf Branding precedence, by tenancy posture (`OS_TENANCY_POSTURE`): -- **`single`** — three fields read the `branding` settings namespace on every request, so a save in **Setup → Branding** shows on the next page load with no restart: `workspace_name` → `productName`, `logo_url` → `logoUrl`, `accent_color` → `brandColor`. Each takes the first source that is set: the setting (a value saved in Setup, or the settings layer's own `OS_BRANDING_WORKSPACE_NAME` / `OS_BRANDING_LOGO_URL` / `OS_BRANDING_ACCENT_COLOR`, which outranks a saved value and locks the field), then the host's `RuntimeConfigPlugin` option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`, then the built-in default. The Branding page's own default for `workspace_name` (`ObjectStack`) is never served to the console. A set `workspace_name` also names sign-in emails, the authenticator app entry and the MCP sign-in, ahead of `OS_APP_NAME`. +- **`single`** — three fields read the `branding` settings namespace on every request, so a save in **Setup → Branding** shows on the next page load with no restart: `workspace_name` → `productName`, `logo_url` → `logoUrl`, `accent_color` → `brandColor`. Each takes the first source that is set: the setting (a value saved in Setup, or the settings layer's own `OS_BRANDING_WORKSPACE_NAME` / `OS_BRANDING_LOGO_URL` / `OS_BRANDING_ACCENT_COLOR`, which outranks a saved value and locks the field), then the host's `RuntimeConfigPlugin` option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`, then the built-in default. The Branding page's `workspace_name` (labelled **Product name**) declares no default of its own: left empty, the name comes from the sources after it. A set `workspace_name` also names sign-in emails, the authenticator app entry and the MCP sign-in, ahead of `OS_APP_NAME`. - **`group` / `isolated`** — the `branding` namespace is not offered: it is absent from `GET /api/settings`, `GET` and `PUT /api/settings/branding` answer `404 UNKNOWN_NAMESPACE`, and Setup lists no Branding entry. The `OS_BRANDING_*` variables are not read. The console's branding is the host option, then the variables in this table, then the default, and the auth product name is the `OS_APP_NAME` chain alone. A walled deployment that set `OS_BRANDING_WORKSPACE_NAME` moves the value to `OS_APP_NAME`. On every posture `productShortName`, `pwaDescription` and `pwaThemeColor` follow the name and colour actually served unless set themselves, and `OS_PRODUCT_STAGE` and `OS_FAVICON_URL` have no setting above them.