Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/25347-branding-product-name.md
Original file line number Diff line number Diff line change
@@ -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
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 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).
13 changes: 13 additions & 0 deletions .changeset/25347-organization-name-timezone-help.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
description: "Organizations for multi-tenant grouping",
fields: {
name: {
label: "Name"
label: "Name",
help: "The organization's own name. The product name the console shows is a different setting, set in Settings → Branding."
},
slug: {
label: "Slug",
Expand All @@ -545,7 +546,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
description: "Organizaciones para agrupación multi-tenant",
fields: {
name: {
label: "Nombre"
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."
},
slug: {
label: "Slug",
Expand All @@ -545,7 +546,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
description: "マルチテナントグループ化のための組織",
fields: {
name: {
label: "名前"
label: "名前",
help: "組織自体の名前。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。"
},
slug: {
label: "スラッグ",
Expand All @@ -545,7 +546,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
timezone: {
label: "タイムゾーン",
help: "この組織で日付の境界を計算する IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないすべてのビジネスユニットが継承するルートの既定値です。未設定の場合は UTC です。"
help: "IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないビジネスユニットのルートの既定値として保存されます。現在、日付の計算には影響しません。数式、レポート、表示される日付は 設定 → ローカリゼーション → 既定のタイムゾーン を使います。"
},
parent_organization_id: {
label: "親組織",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
description: "用于多租户分组的组织",
fields: {
name: {
label: "名称"
label: "名称",
help: "组织自身的名称。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。"
},
slug: {
label: "标识符",
Expand All @@ -545,7 +546,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
timezone: {
label: "时区",
help: "该组织计算日期边界所用的 IANA 时区(如 UTC、Asia/Shanghai)——所有未自行设置时区的业务单元都继承的根默认值。未设置表示 UTC。"
help: "IANA 时区(如 UTC、Asia/Shanghai),作为未自行设置时区的业务单元的根默认值保存。它目前不会改变任何日期计算:公式、报表和显示的日期使用 设置 → 本地化 → 默认时区。"
},
parent_organization_id: {
label: "上级组织",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { description?: string; readonly?: boolean }>)[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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,18 @@ 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 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,
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.',
group: 'Identity',
}),

Expand Down Expand Up @@ -370,6 +376,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
Expand All @@ -389,7 +401,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',
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(/the Name on the Organization record/);
expect(name().description).toMatch(/Leave empty/);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 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
*
Expand Down Expand Up @@ -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 the Name on the Organization record.' },

{ type: 'group', id: 'appearance', label: 'Appearance', required: false },
{ type: 'color', key: 'accent_color', label: 'Accent colour', required: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading
Loading