From 40cf9591d77f6816e935a098ca2fb463d1cc7b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:42:00 +0000 Subject: [PATCH 01/11] wip(service-settings): branding manifest v2, posture-gated namespace, pins Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../src/runtime-config-plugin.ts | 211 +++++++++++++++--- packages/services/service-settings/README.md | 2 +- .../src/manifests/branding.manifest.test.ts | 57 +++++ .../src/manifests/branding.manifest.ts | 72 ++++-- .../src/settings-branding-posture.pin.test.ts | 171 ++++++++++++++ .../src/settings-routes.test.ts | 84 +++++++ .../src/settings-service-plugin.ts | 107 ++++++++- .../src/settings-service.test.ts | 105 ++++----- .../service-settings/src/translations/en.ts | 24 +- .../src/translations/es-ES.ts | 26 ++- .../src/translations/ja-JP.ts | 26 ++- .../src/translations/zh-CN.ts | 22 +- 12 files changed, 774 insertions(+), 133 deletions(-) create mode 100644 packages/services/service-settings/src/manifests/branding.manifest.test.ts create mode 100644 packages/services/service-settings/src/settings-branding-posture.pin.test.ts diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index c717217943d..1cedb39aa72 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -45,6 +45,40 @@ * Host option only, no env var: the value belongs to the distribution whose * control plane serves the page, and the cloud subclass fills it. * + * ## `branding` — the Branding settings, then the deployment's own (#24766) + * + * Three fields read the `branding` settings namespace on EVERY request, so a + * save in Setup → Branding shows on the console's and the sign-in page's next + * load (the maintainer's ruling B′): + * + * productName ← branding.workspace_name + * logoUrl ← branding.logo_url + * brandColor ← branding.accent_color + * + * A value somebody set (any `source` but `'default'`: saved in Setup, or + * pinned by the settings layer's own `OS_BRANDING_*` override) wins. A key + * nobody set falls back to the deployment's branding exactly as before — the + * host's constructor option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` / + * `OS_BRAND_COLOR`, then the built-in default — so the settings layer's + * manifest default is never served. The derived fields follow the served + * values unless configured themselves: `productShortName` and `pwaDescription` + * the name, `pwaThemeColor` the colour. `brandColor` keeps its one meaning: + * the console's browser and installed-app theme colour, beside each app's own + * `AppBranding` colours, with no precedence between them. + * + * ⛔ No tenant data reaches this anonymous door under a walled posture. The + * settings service offers the namespace only where no organization wall + * stands (`service-settings`' `UNWALLED_ONLY_NAMESPACES`), and this door reads + * it only when the service lists it — absent, there is nothing to read and the + * deployment's branding is all that is served. Present, the read names no + * organization, which under `single` resolves the deployment's one + * organization, the same read `plugin-auth` makes for `workspace_name`. No + * organization is resolved from the request before sign-in. + * + * A read that fails is not this door's failure: the deployment's branding is + * served and one `warn` names the cause, so the sign-in page never 500s over a + * settings outage. + * * ## `branding.stage` — a documented knob that this runtime never sent (#9252) * * The Console's `PreviewBadge` reads `branding.stage` to decide whether to show @@ -243,6 +277,38 @@ interface EnvRegistrySurface { resolveHostname?(hostname: string): Promise; } +/** The settings namespace three `branding` fields read (#24766). */ +const BRANDING_SETTINGS_NAMESPACE = 'branding'; + +/** Which `branding` settings key each served field reads (#24766, ruling B′). */ +const BRANDING_SETTINGS_KEYS = { + productName: 'workspace_name', + logoUrl: 'logo_url', + brandColor: 'accent_color', +} as const; + +type SettingsBrandedField = keyof typeof BRANDING_SETTINGS_KEYS; + +/** The served fields a saved Branding setting supplied, each only when one was set. */ +type SavedBranding = Partial>; + +/** + * The slice of the `settings` service this door reads. Declared structurally, + * as `plugin-auth` declares its own: this package does not depend on + * `@objectstack/service-settings`, and a host may register any settings + * service. `listManifests` is how the door learns whether the namespace is + * offered at all; a service without it is treated as not offering it, which is + * the direction that serves no tenant data. + */ +interface BrandingSettingsSurface { + listManifests?(): ReadonlyArray<{ namespace?: unknown; specifiers?: ReadonlyArray<{ key?: unknown }> }>; + getMany?( + namespace: string, + keys: readonly string[], + ctx?: Record, + ): Promise>; +} + /** * The marketplace HTTP namespace, and the one sub-path inside it that is NOT * a browse surface. @@ -580,7 +646,8 @@ export interface RuntimeConfigPluginConfig { * Product name shown in browser title, splash screen, and other * client chrome. Operators can override per-deployment (white-label, * regional rebrands). Falls back to `OS_PRODUCT_NAME` env var, then - * to the default `'ObjectOS'`. + * to the default `'ObjectOS'`. A saved `branding.workspace_name` + * setting outranks all three, read per request (#24766). */ productName?: string; /** Short product name (PWA shortName, compact spots). Defaults to productName. */ @@ -602,11 +669,17 @@ export interface RuntimeConfigPluginConfig { * refused and warned about at mount time — never forwarded. */ stage?: PlatformStage; - /** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */ + /** + * Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. + * A saved `branding.logo_url` setting outranks both, read per request (#24766). + */ logoUrl?: string; /** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */ faviconUrl?: string; - /** Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var. */ + /** + * Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var. + * A saved `branding.accent_color` setting outranks both, read per request (#24766). + */ brandColor?: string; /** PWA manifest description. Falls back to OS_PWA_DESCRIPTION env var. Default: " — runtime console". */ pwaDescription?: string; @@ -679,8 +752,16 @@ export class RuntimeConfigPlugin implements Plugin { private readonly installLocalCeiling: boolean; private readonly aiStudio: boolean; private readonly singleEnvironment: boolean; + /** + * The deployment's product name — host option, `OS_PRODUCT_NAME`, then + * `'ObjectOS'`. Served when no Branding setting names one (#24766). + */ private readonly productName: string; - private readonly productShortName: string; + /** + * The configured short name (host option or `OS_PRODUCT_SHORT_NAME`), or + * `undefined` to derive it from the product name actually served. + */ + private readonly configuredShortName: string | undefined; /** Resolved stage, or `undefined` for "send no key" (unset or refused). */ private readonly stage: PlatformStage | undefined; /** @@ -695,11 +776,20 @@ export class RuntimeConfigPlugin implements Plugin { private readonly upgradeUrl: string | undefined; /** The refused spelling, kept so `start()` can name it once — same reason as `refusedStage`. */ private readonly refusedUpgradeUrl: string | undefined; + /** The deployment's logo (host option, then `OS_LOGO_URL`); a Branding setting outranks it. */ private readonly logoUrl: string | undefined; private readonly faviconUrl: string | undefined; + /** The deployment's brand colour (host option, then `OS_BRAND_COLOR`); a Branding setting outranks it. */ private readonly brandColor: string | undefined; - private readonly pwaDescription: string; - private readonly pwaThemeColor: string; + /** Configured (host option or env), or `undefined` to derive from the served product name. */ + private readonly configuredPwaDescription: string | undefined; + /** Configured (host option or env), or `undefined` to derive from the served brand colour. */ + private readonly configuredPwaThemeColor: string | undefined; + /** + * Whether the last Branding settings read failed and was reported — one + * `warn` per run of failures, not one per request. + */ + private reportedBrandingReadFailure = false; /** * The resolved sink served as `telemetry.errorReporting`, or `undefined` * for "serve no block" — unset, refused, or lowered by the posture. @@ -727,7 +817,7 @@ export class RuntimeConfigPlugin implements Plugin { const envName = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_NAME : undefined)?.trim(); const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim(); this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS'; - this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName; + this.configuredShortName = config.productShortName ?? envShort; // Same precedence as every branding key above — the HOST's explicit // option wins, the env var is the operator's fallback — but resolved // through the closed set, so an unrecognised spelling from either door @@ -758,8 +848,10 @@ export class RuntimeConfigPlugin implements Plugin { this.logoUrl = config.logoUrl ?? envLogoUrl; this.faviconUrl = config.faviconUrl ?? envFaviconUrl; this.brandColor = config.brandColor ?? envBrandColor; - this.pwaDescription = config.pwaDescription ?? envPwaDescription ?? `${this.productName} — runtime console`; - this.pwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor ?? this.brandColor ?? '#4f46e5'; + // The two derived defaults are computed per request (`brandingBlock`), + // from the name and colour actually served (#24766). + this.configuredPwaDescription = config.pwaDescription ?? envPwaDescription; + this.configuredPwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor; // Client error reporting (#12681). Same precedence as every branding // key above — the HOST's explicit option wins, the env var is the @@ -992,22 +1084,9 @@ export class RuntimeConfigPlugin implements Plugin { // aiStudio + autoPublishAiBuilds + any distribution keys. ...features, }, - branding: { - productName: this.productName, - productShortName: this.productShortName, - // Spread, not `stage: this.stage` — the sibling keys - // below may serialize as `undefined` (JSON.stringify - // drops them) but this one is asserted on by KEY - // PRESENCE, so it must never exist as a - // present-and-undefined property on the object handed - // to a non-JSON consumer or a test. - ...(this.stage !== undefined ? { stage: this.stage } : {}), - logoUrl: this.logoUrl, - faviconUrl: this.faviconUrl, - brandColor: this.brandColor, - pwaDescription: this.pwaDescription, - pwaThemeColor: this.pwaThemeColor, - }, + // Read per request (#24766): a Branding save shows on the + // next load, with no restart. + branding: this.brandingBlock(await this.readSavedBranding(ctx)), // Its OWN namespace, deliberately not a member of // `features` (#12681). That map is open-ended and a host's // `resolveFeatures` hook merges arbitrary keys into it @@ -1037,4 +1116,86 @@ export class RuntimeConfigPlugin implements Plugin { rawApp.get('/api/v1/studio/runtime-config', handler); }); }; + + /** + * The served `branding` block: each settings-backed field from `saved` when + * a Branding setting supplied it, else the deployment's own value; the + * derived fields follow what is served unless configured themselves. + */ + private brandingBlock(saved: SavedBranding) { + const productName = saved.productName ?? this.productName; + const brandColor = saved.brandColor ?? this.brandColor; + return { + productName, + productShortName: (this.configuredShortName ?? productName).trim() || productName, + // Spread, not `stage: this.stage` — the sibling keys + // below may serialize as `undefined` (JSON.stringify + // drops them) but this one is asserted on by KEY + // PRESENCE, so it must never exist as a + // present-and-undefined property on the object handed + // to a non-JSON consumer or a test. + ...(this.stage !== undefined ? { stage: this.stage } : {}), + logoUrl: saved.logoUrl ?? this.logoUrl, + faviconUrl: this.faviconUrl, + brandColor, + pwaDescription: this.configuredPwaDescription ?? `${productName} — runtime console`, + pwaThemeColor: this.configuredPwaThemeColor ?? brandColor ?? '#4f46e5', + }; + } + + /** + * The Branding settings somebody set, for this request (#24766). + * + * Empty — the deployment's branding is served — when there is no `settings` + * service, when it does not list the `branding` namespace (it withholds it + * under a walled posture, so nothing tenant-owned can reach this anonymous + * door), when no key was set (`source: 'default'`: the manifest default is + * the settings page's, never this door's), and when the read fails. Only + * the keys the registered manifest declares are asked for: `getMany` + * refuses a whole batch over one undeclared key. + * + * The read names no organization. Under `single` that resolves the one + * organization's row; it is the read `plugin-auth` already makes for + * `workspace_name`. + */ + private async readSavedBranding(ctx: PluginContext): Promise { + let settings: BrandingSettingsSurface | undefined; + try { settings = ctx.getService('settings'); } catch { return {}; } + if (typeof settings?.listManifests !== 'function' || typeof settings.getMany !== 'function') return {}; + try { + const manifest = settings.listManifests().find((m) => m?.namespace === BRANDING_SETTINGS_NAMESPACE); + if (!manifest) return {}; + const declared = new Set((manifest.specifiers ?? []).map((s) => s?.key)); + const fields = (Object.keys(BRANDING_SETTINGS_KEYS) as SettingsBrandedField[]) + .filter((field) => declared.has(BRANDING_SETTINGS_KEYS[field])); + if (fields.length === 0) return {}; + const resolved = await settings.getMany( + BRANDING_SETTINGS_NAMESPACE, + fields.map((field) => BRANDING_SETTINGS_KEYS[field]), + {}, + ); + const saved: SavedBranding = {}; + for (const field of fields) { + const entry = resolved?.[BRANDING_SETTINGS_KEYS[field]]; + if (!entry || entry.source === 'default') continue; + const value = typeof entry.value === 'string' ? entry.value.trim() : ''; + if (value !== '') saved[field] = value; + } + this.reportedBrandingReadFailure = false; + return saved; + } catch (err: any) { + // A functional degradation, so `warn`: the sign-in page shows the + // deployment's branding, visibly, and nothing claimed-persisted is + // lost. Once per run of failures — this door answers every page load. + if (!this.reportedBrandingReadFailure) { + this.reportedBrandingReadFailure = true; + ctx.logger?.warn?.( + `[RuntimeConfigPlugin] could not read the Branding settings (${err?.message ?? err}) — ` + + 'runtime/config serves the deployment branding (the host options, OS_PRODUCT_NAME / ' + + 'OS_LOGO_URL / OS_BRAND_COLOR) until a read succeeds.', + ); + } + return {}; + } + } } diff --git a/packages/services/service-settings/README.md b/packages/services/service-settings/README.md index a22ca56a028..910b982ce0d 100644 --- a/packages/services/service-settings/README.md +++ b/packages/services/service-settings/README.md @@ -107,7 +107,7 @@ the following built-in manifests are pre-registered out of the box: |----------------|-----------------------------|---------------------------------------------| | `mail` | `EmailServicePlugin` | SMTP / Resend / Postmark + `mail/test` | | `storage` | `StorageServicePlugin` | Local FS / S3 + encrypted secret + `storage/test` | -| `branding` | (built-in fallback) | Organization name, logo, accent colour | +| `branding` | (built-in fallback) | Name, logo and colour served to the console by `RuntimeConfigPlugin` (name also read by `plugin-auth`); offered under the `single` tenancy posture only | | `feature_flags`| (built-in fallback) | Opt-in experimental features | ## License diff --git a/packages/services/service-settings/src/manifests/branding.manifest.test.ts b/packages/services/service-settings/src/manifests/branding.manifest.test.ts new file mode 100644 index 00000000000..95b3cada4cc --- /dev/null +++ b/packages/services/service-settings/src/manifests/branding.manifest.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Branding manifest under the maintainer's ruling B′ (#24766): three keys + * the runtime config serves, two retired keys gone, version 2. + */ + +import { describe, it, expect } from 'vitest'; +import { SettingsManifestSchema } from '@objectstack/spec/system'; +import { brandingSettingsManifest } from './branding.manifest.js'; + +const specs = brandingSettingsManifest.specifiers as Array<{ + type: string; + key?: string; + id?: string; + default?: unknown; +}>; +const byKey = (k: string) => specs.find((s) => s.key === k); + +describe('brandingSettingsManifest', () => { + it('parses against SettingsManifestSchema', () => { + expect(() => SettingsManifestSchema.parse(brandingSettingsManifest)).not.toThrow(); + }); + + it('declares namespace=branding, scope=tenant, version=2', () => { + const parsed = SettingsManifestSchema.parse(brandingSettingsManifest); + expect(parsed.namespace).toBe('branding'); + expect(parsed.scope).toBe('tenant'); + // 2 since two keys left (#24766): the spec's `version` contract is + // "increment when keys are renamed/removed". + expect(parsed.version).toBe(2); + }); + + it('offers exactly the three keys the runtime config serves, in identity/appearance', () => { + expect(specs.filter((s) => s.key).map((s) => s.key)).toEqual([ + 'workspace_name', 'accent_color', 'logo_url', + ]); + expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).toEqual(['identity', 'appearance']); + }); + + it('offers neither retired key: theme_mode and support_email had no reader (#24766)', () => { + // Ruling B′: the runtime config has no counterpart for either, so they are + // retired, not implemented. Offering them was a form that saved values with + // no effect. + for (const key of ['theme_mode', 'support_email']) { + expect(byKey(key), `${key} must not be offered`).toBeUndefined(); + } + }); + + it('accent_color declares no default: an unset colour is the deployment\'s, never a manifest value', () => { + // The runtime config serves only a value somebody SET; a manifest default + // would show on the settings page as a colour the console never uses. + expect(byKey('accent_color')).toBeDefined(); + expect(byKey('accent_color')!.default).toBeUndefined(); + expect(byKey('logo_url')!.default).toBeUndefined(); + }); +}); diff --git a/packages/services/service-settings/src/manifests/branding.manifest.ts b/packages/services/service-settings/src/manifests/branding.manifest.ts index d0aa0c53554..6e63f4cce79 100644 --- a/packages/services/service-settings/src/manifests/branding.manifest.ts +++ b/packages/services/service-settings/src/manifests/branding.manifest.ts @@ -2,13 +2,60 @@ import type { SettingsManifest } from '@objectstack/spec/system'; -/** Branding — workspace identity (name, logo, theme). */ +/** + * Branding — the name, logo and brand colour a single-organization deployment + * shows (#24766, the maintainer's ruling B′ and its amendment). + * + * ## Who reads each key + * + * The console learns its brand from `GET /api/v1/runtime/config` + * (`RuntimeConfigPlugin`, `@objectstack/cloud-connection`), which reads this + * namespace on every request, so a save shows on the next page load: + * + * - `workspace_name` → `branding.productName`: the product name in the + * console chrome, on the sign-in page and in the browser title. It also + * keeps its earlier reader, `plugin-auth`, which uses it as the product name + * in sign-in emails, the authenticator app entry and the MCP sign-in. + * - `logo_url` → `branding.logoUrl`: the sign-in page, the loading screen and + * the home header. + * - `accent_color` → `branding.brandColor`: the browser and installed-app + * (PWA) theme colour. It is not an app's colour: each app keeps the colours + * its own `AppBranding` declares, and no precedence between the two exists. + * + * 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. + * + * ## Single posture only + * + * Under a walled tenancy posture (`group` / `isolated`) the settings service + * does not offer this namespace at all — it is absent from the settings + * listing and a write to it is refused `404 UNKNOWN_NAMESPACE` + * (`settings-service-plugin.ts`). The runtime-config door is anonymous and + * resolves no organization before sign-in, so there the deployment's branding + * is the operator's, through the `OS_*` variables above. + * + * ## Version 2 + * + * `theme_mode` and `support_email` were offered here until #24766 and nothing + * ever read them; the runtime config has no counterpart for either, so they + * were retired rather than implemented. Removing them is why `version` is 2: + * the spec's `SettingsManifest.version` contract is "increment when keys are + * renamed/removed". A `sys_setting` row stored for one of them is kept, never + * deleted: the service no longer resolves it, and a write naming the key is + * refused `400 UNKNOWN_KEY`, the answer every undeclared key gets. + */ export const brandingSettingsManifest: SettingsManifest = { namespace: 'branding', - version: 1, + version: 2, label: 'Branding', icon: 'Palette', - description: 'Organization name, logo, and accent colour.', + description: + 'The name, logo and colour the console and the sign-in page show. ' + + 'A value left unset falls back to the deployment\'s own branding.', scope: 'tenant', readPermission: 'setup.access', writePermission: 'setup.write', @@ -17,20 +64,15 @@ export const brandingSettingsManifest: SettingsManifest = { 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: 'email', key: 'support_email', label: 'Support email', required: false, - description: 'Example: support@example.com' }, + default: 'ObjectStack', 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.' }, { type: 'group', id: 'appearance', label: 'Appearance', required: false }, - { type: 'select', key: 'theme_mode', label: 'Default theme', required: false, default: 'system', - options: [ - { value: 'light', label: 'Light' }, - { value: 'dark', label: 'Dark' }, - { value: 'system', label: 'Match system' }, - ], - }, - { type: 'color', key: 'accent_color', label: 'Accent colour', required: false, default: '#6366f1' }, + { type: 'color', key: 'accent_color', label: 'Accent colour', required: false, + description: 'The browser and installed-app theme colour. Each app keeps its own colours.' }, { type: 'url', key: 'logo_url', label: 'Logo URL', required: false, - description: 'Example: https://…/logo.svg' }, + description: 'Shown on the sign-in page, the loading screen and the home header. Example: https://…/logo.svg' }, ], }; diff --git a/packages/services/service-settings/src/settings-branding-posture.pin.test.ts b/packages/services/service-settings/src/settings-branding-posture.pin.test.ts new file mode 100644 index 00000000000..f0eae7b4073 --- /dev/null +++ b/packages/services/service-settings/src/settings-branding-posture.pin.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Branding namespace is offered only where no organization wall stands + * (#24766, the maintainer's ruling B′ and its amendment). + * + * Its values feed the anonymous runtime-config door, which resolves no + * organization before sign-in. Under a walled posture (`group` / `isolated`) + * the ruling keeps the page away entirely: the settings service does not offer + * the namespace, so it is absent from the listing and a read or write of it is + * refused by the unknown-namespace path every undeclared namespace takes. + * Under `single` nothing changes: the page is offered and saves. + * + * Driven through the REAL `SettingsServicePlugin` production sequence + * (`init` → `start` → the `kernel:ready` hook) with its `tenancy` service on a + * REAL `ObjectKernel`, so the posture reaches the plugin the way it does in a + * deployment. The REST family is the real `registerSettingsRoutes` over the + * service the plugin built; only the caller's identity is injected (an admin + * holding the namespace's own `setup.access` / `setup.write`), because who may + * reach the door is not this file's subject — whether the namespace is there is. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; +import { SettingsServicePlugin } from './settings-service-plugin.js'; +import type { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { + this.routes.set(`${method} ${path}`, handler); + } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +/** How the boot reaches a posture: a `tenancy` service, none at all, or one that cannot answer. */ +type Tenancy = + | { kind: 'service'; posture: 'single' | 'group' | 'isolated' } + | { kind: 'unregistered' } + | { kind: 'no-async-registry' } + | { kind: 'factory-throws' }; + +const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' }); + +async function boot(tenancy: Tenancy) { + // `gracefulShutdown: false` — a fixture kernel must not hook the test + // runner's process signals. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + if (tenancy.kind === 'service') { + kernel.registerService('tenancy', { posture: tenancy.posture }); + } else if (tenancy.kind === 'factory-throws') { + // The registry's own unbranded rejection: registered, and unable to answer. + kernel.registerServiceFactory('tenancy', () => { + throw new Error('tenancy backend unavailable'); + }); + } + const getService = kernel.getService.bind(kernel); + const registerService = kernel.registerService.bind(kernel); + const warnings: string[] = []; + let readyHook: (() => Promise) | undefined; + const ctx: any = { + logger: { info: () => {}, warn: (m: string) => { warnings.push(String(m)); }, error: () => {}, debug: () => {} }, + registerService, + getService, + hook: (event: string, fn: () => Promise) => { if (event === 'kernel:ready') readyHook = fn; }, + // A `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync`. + getKernel: () => (tenancy.kind === 'no-async-registry' ? { getService } : kernel), + }; + + // The bundled manifests and handlers, as `os serve` mounts them. + const plugin = new SettingsServicePlugin({ env: {}, registerRoutes: false }); + await plugin.init(ctx); + await plugin.start(ctx); + await readyHook!(); + + const service = getService('settings') as SettingsService; + const http = new MockHttp(); + registerSettingsRoutes(http, service, { contextFromRequest: admin }); + + const call = async (route: string, req: Partial) => { + const state: { status: number; body?: any } = { status: 200 }; + const res = { + json: vi.fn((data: any) => { state.body = data; }), + send: vi.fn(), + status: vi.fn((code: number) => { state.status = code; return res; }), + header: vi.fn(() => res), + } as unknown as IHttpResponse; + await http.routes.get(route)!({ query: {}, params: {}, headers: {}, ...req } as IHttpRequest, res); + return state; + }; + const listed = async () => + ((await call('GET /api/settings', { method: 'GET' })).body.data.manifests as Array<{ namespace: string }>) + .map((m) => m.namespace); + const putBranding = () => + call('PUT /api/settings/:namespace', { + method: 'PUT', params: { namespace: 'branding' } as any, + body: { workspace_name: 'Acme', logo_url: 'https://acme.test/logo.svg' } as any, + }); + const getBranding = () => + call('GET /api/settings/:namespace', { method: 'GET', params: { namespace: 'branding' } as any }); + + return { service, listed, putBranding, getBranding, warnings }; +} + +describe('the Branding namespace under a walled posture (#24766)', () => { + for (const posture of ['group', 'isolated'] as const) { + describe(`posture '${posture}'`, () => { + it('is absent from the settings listing, while the other tenant namespaces stay', async () => { + const b = await boot({ kind: 'service', posture }); + const namespaces = await b.listed(); + expect(namespaces).not.toContain('branding'); + // Control: the listing is real and the rest of the slate is offered. + expect(namespaces).toEqual(expect.arrayContaining(['company', 'localization', 'feature_flags'])); + expect(b.service.listManifests().map((m) => m.namespace)).not.toContain('branding'); + }); + + it('a PUT to it is refused 404 UNKNOWN_NAMESPACE, never a silent 200', async () => { + const b = await boot({ kind: 'service', posture }); + const state = await b.putBranding(); + expect(state.status).toBe(404); + expect(state.body.error.code).toBe('UNKNOWN_NAMESPACE'); + }); + + it('a GET of it is refused the same way: there is no form to render', async () => { + const b = await boot({ kind: 'service', posture }); + const state = await b.getBranding(); + expect(state.status).toBe(404); + expect(state.body.error.code).toBe('UNKNOWN_NAMESPACE'); + }); + }); + } +}); + +describe('the Branding namespace where no wall stands (#24766)', () => { + const unwalled: Array<[string, Tenancy]> = [ + ["posture 'single'", { kind: 'service', posture: 'single' }], + ['no tenancy service registered', { kind: 'unregistered' }], + ['a host with no async registry', { kind: 'no-async-registry' }], + ]; + for (const [label, tenancy] of unwalled) { + it(`${label}: listed, readable and saved`, async () => { + const b = await boot(tenancy); + expect(await b.listed()).toContain('branding'); + const saved = await b.putBranding(); + expect(saved.status).toBe(200); + expect(saved.body.data.values.logo_url).toMatchObject({ value: 'https://acme.test/logo.svg', source: 'tenant' }); + const read = await b.getBranding(); + expect(read.status).toBe(200); + expect(read.body.data.values.workspace_name).toMatchObject({ value: 'Acme', source: 'tenant' }); + }); + } +}); + +describe('a posture that cannot be read (#24766)', () => { + it('withholds the namespace and says why, rather than guessing there is no wall', async () => { + const b = await boot({ kind: 'factory-throws' }); + expect(await b.listed()).not.toContain('branding'); + // One line names the withheld namespace. + expect(b.warnings.filter((w) => w.includes("'branding'"))).toHaveLength(1); + }); +}); diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index d788fe15256..60c5f0743c2 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -806,3 +806,87 @@ describe('settings-routes — the four retired Localization format keys (#21958) expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #24766 — `theme_mode` and `support_email` left the Branding manifest (the +// maintainer's ruling B′: the runtime config has no counterpart for either, so +// they are retired, not implemented). Under the standing rule that the manifest +// version step carries a retirement, a save naming one of them must be refused +// loudly, by name, through the same REST door an admin's Setup form uses: +// +// - read: not offered and not resolved, even with a stored value; +// - write: refused `400 UNKNOWN_KEY` naming the key, the whole batch with it; +// - the stored row is KEPT and never blocks a save of the live keys. +// +// Seeded store-level, as the Localization rows above are: after the upgrade no +// public write path can produce them. +// ───────────────────────────────────────────────────────────────────────────── + +const RETIRED_BRANDING_ROWS: Record = { + theme_mode: 'dark', + support_email: 'help@acme.test', +}; + +async function makeRetiredBrandingStack() { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(brandingSettingsManifest); + for (const [key, value] of Object.entries(RETIRED_BRANDING_ROWS)) { + await (svc as any).upsertRow({ + namespace: 'branding', key, scope: 'tenant', user_id: null, + value, value_enc: null, encrypted: false, + }); + } + const http = new MockHttp(); + registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); + /** The store's own rows for the two keys, read the way every resolve reads them. */ + const storedRetiredRows = async () => { + const rows = (await (svc as any).loadRows('branding', null, { kind: 'unwalled' })) as Array<{ key: string; value: unknown }>; + return Object.fromEntries(rows.filter((r) => r.key in RETIRED_BRANDING_ROWS).map((r) => [r.key, r.value])); + }; + return { http, storedRetiredRows }; +} + +describe('settings-routes — the two retired Branding keys (#24766)', () => { + it('GET /api/settings/branding offers neither, even with a stored value', async () => { + const { http, storedRetiredRows } = await makeRetiredBrandingStack(); + const { req, res, state } = makeReqRes({ params: { namespace: 'branding' } }); + await http.routes.get('GET /api/settings/:namespace')!(req, res); + + expect(state.status).toBe(200); + const offered = (state.body.data.manifest.specifiers as Array<{ key?: string }>).map((s) => s.key).filter(Boolean); + expect(state.body.data.manifest.version).toBe(2); + for (const key of Object.keys(RETIRED_BRANDING_ROWS)) { + expect(offered, `${key} must not be offered`).not.toContain(key); + } + expect(Object.keys(state.body.data.values).sort()).toEqual(['accent_color', 'logo_url', 'workspace_name']); + expect(await storedRetiredRows()).toEqual(RETIRED_BRANDING_ROWS); + }); + + it('PUT naming one of them is refused 400 UNKNOWN_KEY by name, and nothing in the batch lands', async () => { + const { http, storedRetiredRows } = await makeRetiredBrandingStack(); + for (const key of Object.keys(RETIRED_BRANDING_ROWS)) { + const { req, res, state } = makeReqRes({ + params: { namespace: 'branding' }, + body: { [key]: 'anything', logo_url: 'https://acme.test/logo.svg' }, + }); + await http.routes.get('PUT /api/settings/:namespace')!(req, res); + expect(state.status).toBe(400); + expect(state.body.error.code).toBe('UNKNOWN_KEY'); + expect(state.body.error.details).toEqual({ namespace: 'branding', key }); + } + // The live key sent beside each refused one did not land. + const read = makeReqRes({ params: { namespace: 'branding' } }); + await http.routes.get('GET /api/settings/:namespace')!(read.req, read.res); + expect(read.state.body.data.values.logo_url.source).toBe('default'); + expect(await storedRetiredRows()).toEqual(RETIRED_BRANDING_ROWS); + }); + + it('a stored retired row never blocks saving the live keys', async () => { + const { http, storedRetiredRows } = await makeRetiredBrandingStack(); + const put = makeReqRes({ params: { namespace: 'branding' }, body: { logo_url: 'https://acme.test/logo.svg' } }); + await http.routes.get('PUT /api/settings/:namespace')!(put.req, put.res); + expect(put.state.status).toBe(200); + expect(put.state.body.data.values.logo_url).toMatchObject({ value: 'https://acme.test/logo.svg', source: 'tenant' }); + expect(await storedRetiredRows()).toEqual(RETIRED_BRANDING_ROWS); + }); +}); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 8c8160426d7..a282064cb1d 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -21,7 +21,7 @@ import { type TenancyPostureSource, type TenancyServiceResolver, } from '@objectstack/core'; -import type { TenancyPosture } from '@objectstack/spec/security'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import type { IHttpServer, IDataEngine, IHttpRequest, IAuthService } from '@objectstack/spec/contracts'; import type { SettingsContext } from './settings-service.types.js'; import type { SettingsManifest } from '@objectstack/spec/system'; @@ -51,13 +51,42 @@ import { } from './manifests/index.js'; import { settingsBuiltinTranslations } from './translations/index.js'; +/** + * Namespaces offered only where no organization wall stands (#24766, the + * maintainer's ruling B′ and its amendment). + * + * `branding` is the one member. Its values reach the console through the + * runtime-config door (`GET /api/v1/runtime/config`), which is anonymous and + * resolves no organization before sign-in. Under `single` the one organization + * IS the deployment, so its saved name, logo and colour are the deployment's. + * Under a walled posture (`group` / `isolated`) there is no such thing as "the" + * organization's branding on that door, and the ruling keeps tenant data off + * it: the Branding page is not offered there at all, and the deployment's + * branding is the operator's, through `OS_PRODUCT_NAME` / `OS_LOGO_URL` / + * `OS_BRAND_COLOR`. + * + * "Not offered" is the registry's own answer, so every door agrees without a + * second rule: the namespace is absent from `GET /api/settings`, a read or + * write of it is refused `404 UNKNOWN_NAMESPACE` by the path every unknown + * namespace takes, and the runtime-config door finds nothing to read. + * + * Such a manifest is held back at `init()` and registered from the + * `kernel:ready` hook once the posture IN FORCE is known: during `init()` the + * `tenancy` service may not be registered yet, and the `isolated` posture is + * only in force once `org-scoping` has registered, so a verdict taken there + * could be contradicted later in the same boot. + */ +const UNWALLED_ONLY_NAMESPACES: ReadonlySet = new Set(['branding']); + /** Configuration options for the SettingsServicePlugin. */ export interface SettingsServicePluginOptions { /** * Pre-register these manifests at boot. When omitted, the bundled * builtin manifests (mail / branding / feature_flags) are loaded so * a host gets a working Settings hub out of the box. Pass an empty - * array to opt out entirely. + * array to opt out entirely. A `branding` manifest, bundled or passed + * here, is offered only where no organization wall stands (see + * `UNWALLED_ONLY_NAMESPACES` in this file). */ manifests?: SettingsManifest[]; /** Override the default crypto adapter. */ @@ -122,6 +151,11 @@ export class SettingsServicePlugin implements Plugin { private readonly opts: SettingsServicePluginOptions; private service: SettingsService | null = null; + /** + * Manifests in {@link UNWALLED_ONLY_NAMESPACES}, held back by `init()` until + * the `kernel:ready` hook knows the posture in force. + */ + private readonly heldUntilPostureKnown: SettingsManifest[] = []; constructor(opts: SettingsServicePluginOptions = {}) { this.opts = { @@ -159,11 +193,17 @@ export class SettingsServicePlugin implements Plugin { // `console.error` fallback instead. logger: ctx.logger, }); - for (const m of this.opts.manifests ?? []) this.service.registerManifest(m); - for (const [ns, handlers] of Object.entries(this.opts.actionHandlers ?? {})) { - for (const [id, fn] of Object.entries(handlers)) { - this.service.registerAction(ns, id, fn); + for (const m of this.opts.manifests ?? []) { + if (UNWALLED_ONLY_NAMESPACES.has(m.namespace)) { + this.heldUntilPostureKnown.push(m); + continue; } + this.service.registerManifest(m); + } + for (const ns of Object.keys(this.opts.actionHandlers ?? {})) { + // A held namespace's handlers register with its manifest, or not at all. + if (UNWALLED_ONLY_NAMESPACES.has(ns)) continue; + this.registerActionHandlers(ns); } ctx.registerService('settings', this.service); @@ -186,6 +226,10 @@ export class SettingsServicePlugin implements Plugin { if (!this.service) return; ctx.hook('kernel:ready', async () => { + // First, before the engine binds (its change event names every + // registered namespace) and before any later `kernel:ready` reader runs. + await this.offerUnwalledOnlyNamespaces(ctx); + // Contribute built-in settings translations into the i18n service. // Done in `kernel:ready` (not `init`) because the i18n service plugin // is typically registered AFTER capability-loaded service plugins. @@ -481,6 +525,57 @@ export class SettingsServicePlugin implements Plugin { return tenancy ? classifyAdmissionTenancyPosture(tenancy) : undefined; } + /** Register the configured action handlers of one namespace. */ + private registerActionHandlers(namespace: string): void { + for (const [id, fn] of Object.entries(this.opts.actionHandlers?.[namespace] ?? {})) { + this.service!.registerAction(namespace, id, fn); + } + } + + /** + * Offer the held {@link UNWALLED_ONLY_NAMESPACES} manifests, or withhold them + * for the life of the process, by the posture IN FORCE. + * + * Read the way this plugin's door and service read it + * ({@link resolveAdmissionTenancyPosture}): no `tenancy` service, or a host + * with no async registry, is a deployment with no wall this plugin can know + * of, and offers them — the reading every lean kernel has always had. Read + * once, here: by `kernel:ready` every `init()` has run, so the `tenancy` + * service and `org-scoping` are as registered as they will ever be. + * + * A posture that cannot be read withholds them. Offering a namespace whose + * values the anonymous runtime-config door would then serve is the one + * direction a guess must not take; the line says what is missing and why. + */ + private async offerUnwalledOnlyNamespaces(ctx: PluginContext): Promise { + if (this.heldUntilPostureKnown.length === 0) return; + const names = this.heldUntilPostureKnown.map((m) => `'${m.namespace}'`).join(', '); + let posture: TenancyPosture | undefined; + try { + posture = await this.resolveAdmissionTenancyPosture(ctx); + } catch (err: any) { + ctx.logger?.warn?.( + `SettingsServicePlugin: the tenancy posture could not be read (${err?.message ?? err}) — ` + + `settings namespace(s) ${names} are NOT offered on this boot: they are offered only where no ` + + 'organization wall stands, and that could not be established. The deployment branding ' + + '(OS_PRODUCT_NAME / OS_LOGO_URL / OS_BRAND_COLOR) still applies.', + ); + return; + } + if (posture !== undefined && postureEnforcesWall(posture)) { + ctx.logger?.info?.( + `SettingsServicePlugin: tenancy posture '${posture}' walls organizations apart — settings ` + + `namespace(s) ${names} are not offered. The deployment branding is the operator's: ` + + 'OS_PRODUCT_NAME / OS_LOGO_URL / OS_BRAND_COLOR.', + ); + return; + } + for (const m of this.heldUntilPostureKnown) { + this.service!.registerManifest(m); + this.registerActionHandlers(m.namespace); + } + } + /** * Phase 3: build a `sys_secret`-backed implementation of * `SettingsSecretStore`. The store bypasses the tenant audit diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 4a6f8bf48ed..e82e515da6e 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -679,8 +679,10 @@ describe('SettingsService — save-time validation (declared options are enforce * `locked: true`. So the values #5094/#5133 retired from `mail.provider` could * walk back in through the one door with no gate on it — * `OS_MAIL_PROVIDER=sendgrid` reached the mail plugin unchallenged — and a plain - * typo (`OS_BRANDING_THEME_MODE=drak`) was served to every consumer as a normal - * value with a normal-looking provenance. + * typo (`OS_BRANDING_THEME_MODE=drak`, a key since retired) was served to every + * consumer as a normal value with a normal-looking provenance. The cases below + * use `localization.locale`, a domain-less select, since `branding.theme_mode` + * left the Branding manifest (#24766). * * Per the ruling on #5204 an offending override is IGNORED, not repaired: the * value falls through to the next cascade layer, and the read API reports THAT @@ -695,12 +697,13 @@ describe('SettingsService — env overrides are checked against declared options it('ignores an out-of-table env value and resolves the manifest default instead', async () => { const { errors, logger } = spyLogger(); - // The issue's own example: a typo, one transposition away from 'dark'. - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); - svc.registerManifest(brandingSettingsManifest); + // A typo one transposition away from 'en-US' (the issue's own example was + // `OS_BRANDING_THEME_MODE=drak`, a key since retired). + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'en-SU' }, logger }); + svc.registerManifest(localizationSettingsManifest); - const r = await svc.get('branding', 'theme_mode'); - expect(r.value).toBe('system'); // the manifest default, not 'drak' + const r = await svc.get('localization', 'locale'); + expect(r.value).toBe('en-US'); // the manifest default, not 'en-SU' expect(r.source).toBe('default'); // The override is not in force, so it does not lock anything either. expect(r.locked).toBe(false); @@ -710,21 +713,21 @@ describe('SettingsService — env overrides are checked against declared options expect(r.cascadeChain?.some((e) => e.scope === 'env')).toBe(false); expect(errors).toHaveLength(1); - expect(errors[0]).toContain('OS_BRANDING_THEME_MODE'); - expect(errors[0]).toContain('drak'); - expect(errors[0]).toContain('light, dark, system'); // the legal value set + expect(errors[0]).toContain('OS_LOCALIZATION_LOCALE'); + expect(errors[0]).toContain('en-SU'); + expect(errors[0]).toContain('en-US, zh-CN, ja-JP, es-ES'); // the legal value set expect(errors[0]).toContain('IGNORED'); }); it('falls back to the next cascade layer, not straight to the default', async () => { // "Ignored" means the env layer is skipped, not that the whole cascade is. const { errors, logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); - svc.registerManifest(brandingSettingsManifest); - await svc.set('branding', 'theme_mode', 'dark'); + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'en-SU' }, logger }); + svc.registerManifest(localizationSettingsManifest); + await svc.set('localization', 'locale', 'ja-JP'); - const r = await svc.get('branding', 'theme_mode'); - expect(r.value).toBe('dark'); + const r = await svc.get('localization', 'locale'); + expect(r.value).toBe('ja-JP'); expect(r.source).toBe('tenant'); expect(r.locked).toBe(false); expect(errors).toHaveLength(1); @@ -734,14 +737,14 @@ describe('SettingsService — env overrides are checked against declared options // The regression pin for the untouched path: a legal override keeps its // precedence, its `locked: true`, and its reason string. const { errors, logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'dark' }, logger }); - svc.registerManifest(brandingSettingsManifest); + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'ja-JP' }, logger }); + svc.registerManifest(localizationSettingsManifest); - const r = await svc.get('branding', 'theme_mode'); - expect(r.value).toBe('dark'); + const r = await svc.get('localization', 'locale'); + expect(r.value).toBe('ja-JP'); expect(r.source).toBe('env'); expect(r.locked).toBe(true); - expect(r.lockedReason).toContain('OS_BRANDING_THEME_MODE'); + expect(r.lockedReason).toContain('OS_LOCALIZATION_LOCALE'); expect(r.cascadeChain?.[0]).toMatchObject({ scope: 'env', effective: true }); expect(errors).toHaveLength(0); }); @@ -751,10 +754,10 @@ describe('SettingsService — env overrides are checked against declared options // locked, `setMany` refuses. Pinned here so the coherence fix below cannot be // over-applied into "env never locks anything". const { logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'dark' }, logger }); - svc.registerManifest(brandingSettingsManifest); - expect((await svc.get('branding', 'theme_mode')).locked).toBe(true); - await expect(svc.set('branding', 'theme_mode', 'light')).rejects.toBeInstanceOf( + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'ja-JP' }, logger }); + svc.registerManifest(localizationSettingsManifest); + expect((await svc.get('localization', 'locale')).locked).toBe(true); + await expect(svc.set('localization', 'locale', 'zh-CN')).rejects.toBeInstanceOf( SettingsLockedError, ); }); @@ -766,14 +769,14 @@ describe('SettingsService — env overrides are checked against declared options // `SETTINGS_LOCKED`, leaving the key configurable by nothing at all — env // value ignored, UI refused — a lockout only an env edit could clear. const { logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); - svc.registerManifest(brandingSettingsManifest); + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'en-SU' }, logger }); + svc.registerManifest(localizationSettingsManifest); - expect((await svc.get('branding', 'theme_mode')).locked).toBe(false); + expect((await svc.get('localization', 'locale')).locked).toBe(false); // …and the write the read surface just advertised as possible really is. - await expect(svc.set('branding', 'theme_mode', 'light')).resolves.toBeDefined(); - const after = await svc.get('branding', 'theme_mode'); - expect(after.value).toBe('light'); + await expect(svc.set('localization', 'locale', 'zh-CN')).resolves.toBeDefined(); + const after = await svc.get('localization', 'locale'); + expect(after.value).toBe('zh-CN'); expect(after.source).toBe('tenant'); }); @@ -857,11 +860,11 @@ describe('SettingsService — env overrides are checked against declared options // per-read line would be a firehose — and AGENTS.md's "Degradation log // levels" names training people to skim `error` as the mirror-image failure. const { errors, logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); - svc.registerManifest(brandingSettingsManifest); // one report here - for (let i = 0; i < 5; i++) await svc.get('branding', 'theme_mode'); - await svc.getNamespace('branding'); - await svc.getNamespace('branding'); + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'en-SU' }, logger }); + svc.registerManifest(localizationSettingsManifest); // one report here + for (let i = 0; i < 5; i++) await svc.get('localization', 'locale'); + await svc.getNamespace('localization'); + await svc.getNamespace('localization'); expect(errors).toHaveLength(1); }); @@ -870,16 +873,16 @@ describe('SettingsService — env overrides are checked against declared options // a live `process.env` reference, so a newly-set bad override must not // inherit an earlier line's silence. const { errors, logger } = spyLogger(); - const env: Record = { OS_BRANDING_THEME_MODE: 'drak' }; + const env: Record = { OS_LOCALIZATION_LOCALE: 'en-SU' }; const svc = new SettingsService({ env, logger }); - svc.registerManifest(brandingSettingsManifest); + svc.registerManifest(localizationSettingsManifest); expect(errors).toHaveLength(1); - env.OS_BRANDING_THEME_MODE = 'lite'; - const r = await svc.get('branding', 'theme_mode'); + env.OS_LOCALIZATION_LOCALE = 'zz-ZZ'; + const r = await svc.get('localization', 'locale'); expect(r.source).toBe('default'); expect(errors).toHaveLength(2); - expect(errors[1]).toContain('lite'); + expect(errors[1]).toContain('zz-ZZ'); }); it('the read surface reports the layer actually in force, never a phantom env', async () => { @@ -888,19 +891,19 @@ describe('SettingsService — env overrides are checked against declared options // field is pinned by the deployment and not editable — about a value nothing // is using. const { logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); - svc.registerManifest(brandingSettingsManifest); - await svc.set('branding', 'theme_mode', 'light'); + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'en-SU' }, logger }); + svc.registerManifest(localizationSettingsManifest); + await svc.set('localization', 'locale', 'zh-CN'); - const payload = await svc.getNamespace('branding'); - expect(payload.values.theme_mode).toMatchObject({ - value: 'light', + const payload = await svc.getNamespace('localization'); + expect(payload.values.locale).toMatchObject({ + value: 'zh-CN', source: 'tenant', locked: false, }); - expect(payload.values.theme_mode.cascadeChain?.some((e) => e.scope === 'env')).toBe(false); + expect(payload.values.locale.cascadeChain?.some((e) => e.scope === 'env')).toBe(false); // A sibling key with a legal env override is unaffected in the same payload. - expect(payload.values.workspace_name.source).toBe('default'); + expect(payload.values.timezone.source).toBe('default'); }); it('falls back to console.error when no logger is injected', async () => { @@ -909,10 +912,10 @@ describe('SettingsService — env overrides are checked against declared options // very failure #5204 is about. const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { - const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' } }); - svc.registerManifest(brandingSettingsManifest); + const svc = new SettingsService({ env: { OS_LOCALIZATION_LOCALE: 'en-SU' } }); + svc.registerManifest(localizationSettingsManifest); expect(spy).toHaveBeenCalledTimes(1); - expect(String(spy.mock.calls[0]![0])).toContain('OS_BRANDING_THEME_MODE'); + expect(String(spy.mock.calls[0]![0])).toContain('OS_LOCALIZATION_LOCALE'); } finally { spy.mockRestore(); } diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 8784d7d80de..201481224f2 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -64,20 +64,28 @@ export const en: PlatformTranslationData = { branding: { title: 'Branding', - description: 'Organization name, logo, and accent colour.', + description: + 'The name, logo and colour the console and the sign-in page show. ' + + 'A value left unset falls back to the deployment\'s own branding.', groups: { identity: { title: 'Identity' }, appearance: { title: 'Appearance' }, }, keys: { - workspace_name: { label: 'Organization name' }, - support_email: { label: 'Support email', help: 'Example: support@example.com' }, - theme_mode: { - label: 'Default theme', - options: { light: 'Light', dark: 'Dark', system: 'Match system' }, + workspace_name: { + label: 'Organization 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.', + }, + accent_color: { + label: 'Accent colour', + help: 'The browser and installed-app theme colour. Each app keeps its own colours.', + }, + logo_url: { + label: 'Logo URL', + help: 'Shown on the sign-in page, the loading screen and the home header. Example: https://…/logo.svg', }, - accent_color: { label: 'Accent colour' }, - logo_url: { label: 'Logo URL', help: 'Example: https://…/logo.svg' }, }, }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index e3ab1714929..cfe5e228eca 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -60,20 +60,28 @@ export const esES: PlatformTranslationData = { branding: { title: 'Marca', - description: 'Nombre de la organización, logotipo y color de acento.', + description: + 'El nombre, el logotipo y el color que muestran la consola y la página de inicio de sesión. ' + + 'Un valor sin definir usa la marca propia de la implementación.', groups: { identity: { title: 'Identidad' }, appearance: { title: 'Apariencia' }, }, keys: { - workspace_name: { label: 'Nombre de la organización' }, - support_email: { label: 'Correo de soporte', help: 'Ejemplo: support@example.com' }, - theme_mode: { - label: 'Tema predeterminado', - options: { light: 'Claro', dark: 'Oscuro', system: 'Según el sistema' }, - }, - accent_color: { label: 'Color de acento' }, - logo_url: { label: 'URL del logotipo', help: 'Ejemplo: https://…/logo.svg' }, + workspace_name: { + label: 'Nombre de la organización', + 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.', + }, + accent_color: { + label: 'Color de acento', + help: 'El color de tema del navegador y de la aplicación instalada. Cada aplicación conserva sus propios colores.', + }, + logo_url: { + label: 'URL del logotipo', + help: 'Se muestra en la página de inicio de sesión, la pantalla de carga y el encabezado de inicio. Ejemplo: https://…/logo.svg', + }, }, }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 4de0b285ee8..b755c987dc0 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -60,20 +60,28 @@ export const jaJP: PlatformTranslationData = { branding: { title: 'ブランディング', - description: '組織名・ロゴ・アクセントカラー。', + description: + 'コンソールとサインインページに表示される名前・ロゴ・カラー。' + + '未設定の値はデプロイメント自身のブランディングに従います。', groups: { identity: { title: 'アイデンティティ' }, appearance: { title: '外観' }, }, keys: { - workspace_name: { label: '組織名' }, - support_email: { label: 'サポートメール', help: '例: support@example.com' }, - theme_mode: { - label: 'デフォルトテーマ', - options: { light: 'ライト', dark: 'ダーク', system: 'システムに従う' }, - }, - accent_color: { label: 'アクセントカラー' }, - logo_url: { label: 'ロゴ URL', help: '例: https://…/logo.svg' }, + workspace_name: { + label: '組織名', + help: + 'コンソール、サインインページ、ブラウザのタイトルに表示される製品名。' + + 'サインインメール、認証アプリ、MCP サインインでも使われます。', + }, + accent_color: { + label: 'アクセントカラー', + help: 'ブラウザとインストール済みアプリのテーマカラー。各アプリは独自のカラーを保持します。', + }, + logo_url: { + label: 'ロゴ URL', + help: 'サインインページ、読み込み画面、ホームのヘッダーに表示されます。例: https://…/logo.svg', + }, }, }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 9ad0b910a54..616f6287a5a 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -100,20 +100,24 @@ export const zhCN: PlatformTranslationData = { branding: { title: '品牌', - description: '组织名称、Logo 与主题色。', + description: '控制台与登录页显示的名称、Logo 与颜色。未设置的值沿用部署自身的品牌。', groups: { identity: { title: '身份' }, appearance: { title: '外观' }, }, keys: { - workspace_name: { label: '组织名称' }, - support_email: { label: '客服邮箱', help: '示例:support@example.com' }, - theme_mode: { - label: '默认主题', - options: { light: '浅色', dark: '深色', system: '跟随系统' }, - }, - accent_color: { label: '主题色' }, - logo_url: { label: 'Logo 链接', help: '示例:https://…/logo.svg' }, + workspace_name: { + label: '组织名称', + help: '控制台、登录页与浏览器标题中的产品名,也用于登录邮件、身份验证器应用与 MCP 登录。', + }, + accent_color: { + label: '主题色', + help: '浏览器与已安装应用的主题色。各应用保留自己的颜色。', + }, + logo_url: { + label: 'Logo 链接', + help: '显示在登录页、加载页与首页顶栏。示例:https://…/logo.svg', + }, }, }, From 587872f974c50100f93efd6aaed243d05e9a8a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:55:54 +0000 Subject: [PATCH 02/11] wip(cloud-connection): runtime-config branding reads the Branding settings per request Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../runtime-config-branding-settings.test.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 packages/cloud-connection/src/runtime-config-branding-settings.test.ts diff --git a/packages/cloud-connection/src/runtime-config-branding-settings.test.ts b/packages/cloud-connection/src/runtime-config-branding-settings.test.ts new file mode 100644 index 00000000000..9d67c462a90 --- /dev/null +++ b/packages/cloud-connection/src/runtime-config-branding-settings.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `branding` reads the Branding settings, then the deployment's own (#24766, + * the maintainer's ruling B′ and its amendment). + * + * productName ← branding.workspace_name + * logoUrl ← branding.logo_url + * brandColor ← branding.accent_color + * + * A value somebody set wins; a key nobody set falls back to the deployment + * (`OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`); the read happens on + * every request, so a save shows on the next load. Under a walled posture the + * settings service does not list the namespace (`service-settings`' own pin, + * `settings-branding-posture.pin.test.ts`), and then this anonymous door must + * not read it at all. + * + * The `settings` service here is a stand-in with the real service's contract + * for the two calls this door makes: `listManifests()` (a trusted in-process + * caller sees every registered manifest) and `getMany()` answering + * `{ value, source }` per key, `source: 'default'` for a key nobody set. Its + * `getMany` ANSWERS even when the namespace is not listed — deliberately, so a + * door that skipped the listing check would serve the saved values and the + * walled-posture pin would go red. This package does not depend on + * `@objectstack/service-settings`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js'; + +const BRANDING_ENV = [ + 'OS_PRODUCT_NAME', 'OS_PRODUCT_SHORT_NAME', 'OS_LOGO_URL', 'OS_BRAND_COLOR', + 'OS_PWA_DESCRIPTION', 'OS_PWA_THEME_COLOR', +] as const; + +/** The manifest defaults the real Branding manifest declares (`accent_color` and `logo_url` declare none). */ +const MANIFEST_DEFAULTS: Record = { workspace_name: 'ObjectStack' }; + +interface Stub { + /** Whether `listManifests()` lists `branding` — false is the walled posture. */ + offered: boolean; + /** Values somebody set, by key, with the `source` the service would report. */ + saved: Record; + /** Make every `getMany` reject. */ + fail?: Error; + /** Every `getMany` call, in order. */ + calls: Array<{ namespace: string; keys: readonly string[]; ctx: unknown }>; +} + +function settingsService(stub: Stub) { + return { + listManifests: () => [ + { namespace: 'localization', specifiers: [{ key: 'timezone' }] }, + ...(stub.offered + ? [{ + namespace: 'branding', + specifiers: [ + { type: 'group', id: 'identity' }, { key: 'workspace_name' }, + { type: 'group', id: 'appearance' }, { key: 'accent_color' }, { key: 'logo_url' }, + ], + }] + : []), + ], + getMany: async (namespace: string, keys: readonly string[], ctx?: unknown) => { + stub.calls.push({ namespace, keys, ctx }); + if (stub.fail) throw stub.fail; + return Object.fromEntries(keys.map((k) => [ + k, + stub.saved[k] ?? { value: MANIFEST_DEFAULTS[k], source: 'default' }, + ])); + }, + }; +} + +/** + * Mount the plugin on a Hono-shaped raw app, the harness shape of + * `runtime-config-stage.test.ts`, with a `settings` service when given one. + * Returns a function serving one request per call, and the warnings. + */ +async function mount(opts: { pluginConfig?: RuntimeConfigPluginConfig; settings?: unknown } = {}) { + let handler: ((c: any) => Promise) | undefined; + const rawApp = { + routes: [] as Array<{ method: string; path: string }>, + get(path: string, h: (c: any) => Promise) { + this.routes.push({ method: 'GET', path }); + if (path === '/api/v1/runtime/config') handler = h; + }, + }; + const warnings: string[] = []; + const ctx: any = { + logger: { info() {}, warn: (m: string) => { warnings.push(String(m)); } }, + getService: (n: string) => { + if (n === 'http-server') return { getRawApp: () => rawApp }; + if (n === 'settings' && opts.settings) return opts.settings; + throw new Error(`no ${n}`); + }, + hooks: [] as Array<() => Promise>, + hook(_e: string, cb: () => Promise) { this.hooks.push(cb); }, + }; + const plugin = new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, ...opts.pluginConfig }); + await plugin.start(ctx); + for (const cb of ctx.hooks) await cb(); + if (!handler) throw new Error('handler not mounted'); + const serve = async () => (await handler!({ req: { header: () => undefined }, json: (b: any) => b })).branding; + return { serve, warnings }; +} + +const newStub = (over: Partial = {}): Stub => ({ offered: true, saved: {}, calls: [], ...over }); + +describe('RuntimeConfigPlugin — branding from the Branding settings (#24766)', () => { + const saved: Record = {}; + beforeEach(() => { + for (const k of BRANDING_ENV) { saved[k] = process.env[k]; delete process.env[k]; } + process.env.OS_PRODUCT_NAME = 'Env Product'; + process.env.OS_LOGO_URL = 'https://env.test/logo.svg'; + process.env.OS_BRAND_COLOR = '#111111'; + }); + afterEach(() => { + for (const k of BRANDING_ENV) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + describe('under single (the namespace is offered)', () => { + it('a saved logo URL is the one served', async () => { + const stub = newStub({ saved: { logo_url: { value: 'https://acme.test/logo.svg', source: 'tenant' } } }); + const { serve } = await mount({ settings: settingsService(stub) }); + expect((await serve()).logoUrl).toBe('https://acme.test/logo.svg'); + }); + + it('an unset logo URL falls back to OS_LOGO_URL', async () => { + const { serve } = await mount({ settings: settingsService(newStub()) }); + expect((await serve()).logoUrl).toBe('https://env.test/logo.svg'); + }); + + it('a saved name is the product name; an unset one falls back to OS_PRODUCT_NAME, never the manifest default', async () => { + const set = await mount({ + settings: settingsService(newStub({ saved: { workspace_name: { value: 'Acme Ops', source: 'tenant' } } })), + }); + expect((await set.serve()).productName).toBe('Acme Ops'); + + // `workspace_name` declares the default 'ObjectStack'; nobody set it. + const unset = await mount({ settings: settingsService(newStub()) }); + expect((await unset.serve()).productName).toBe('Env Product'); + }); + + it('a saved colour is the brand colour; an unset one falls back to OS_BRAND_COLOR', async () => { + const set = await mount({ + settings: settingsService(newStub({ saved: { accent_color: { value: '#e11d48', source: 'tenant' } } })), + }); + expect((await set.serve()).brandColor).toBe('#e11d48'); + + const unset = await mount({ settings: settingsService(newStub()) }); + expect((await unset.serve()).brandColor).toBe('#111111'); + }); + + it('the derived fields follow the served name and colour unless configured themselves', async () => { + const stub = newStub({ + saved: { + workspace_name: { value: 'Acme Ops', source: 'tenant' }, + accent_color: { value: '#e11d48', source: 'tenant' }, + }, + }); + const derived = await mount({ settings: settingsService(stub) }); + expect(await derived.serve()).toMatchObject({ + productShortName: 'Acme Ops', + pwaDescription: 'Acme Ops — runtime console', + pwaThemeColor: '#e11d48', + }); + + process.env.OS_PRODUCT_SHORT_NAME = 'EP'; + process.env.OS_PWA_THEME_COLOR = '#222222'; + const configured = await mount({ settings: settingsService(stub) }); + expect(await configured.serve()).toMatchObject({ productShortName: 'EP', pwaThemeColor: '#222222' }); + }); + + it('a saved value outranks the host option too: the option is the deployment default, like the env var', async () => { + const stub = newStub({ saved: { logo_url: { value: 'https://acme.test/logo.svg', source: 'tenant' } } }); + const { serve } = await mount({ + pluginConfig: { logoUrl: '/host-logo.svg', productName: 'Host Product' }, + settings: settingsService(stub), + }); + const branding = await serve(); + expect(branding.logoUrl).toBe('https://acme.test/logo.svg'); + // Nobody set a name: the host option stays the fallback, ahead of the env var. + expect(branding.productName).toBe('Host Product'); + }); + + it('reads per request, not once at construction: a save shows on the next load', async () => { + const stub = newStub(); + const { serve } = await mount({ settings: settingsService(stub) }); + expect((await serve()).productName).toBe('Env Product'); + stub.saved.workspace_name = { value: 'Renamed', source: 'tenant' }; + expect((await serve()).productName).toBe('Renamed'); + // One batched read per request, of the three declared keys, naming no organization. + expect(stub.calls).toHaveLength(2); + expect(stub.calls[1]).toEqual({ + namespace: 'branding', + keys: ['workspace_name', 'logo_url', 'accent_color'], + ctx: {}, + }); + }); + + it('the settings layer\'s own env override counts as set (source env)', async () => { + const stub = newStub({ saved: { workspace_name: { value: 'Pinned', source: 'env' } } }); + const { serve } = await mount({ settings: settingsService(stub) }); + expect((await serve()).productName).toBe('Pinned'); + }); + + it('a read that fails serves the deployment branding and warns once, never a 500', async () => { + const stub = newStub({ fail: new Error('store down') }); + const { serve, warnings } = await mount({ settings: settingsService(stub) }); + expect(await serve()).toMatchObject({ + productName: 'Env Product', logoUrl: 'https://env.test/logo.svg', brandColor: '#111111', + }); + await serve(); + const lines = warnings.filter((w) => w.includes('store down')); + expect(lines).toHaveLength(1); + }); + }); + + describe('under group / isolated (the namespace is not offered)', () => { + it('the setting is not served on the anonymous door, and the door never reads it', async () => { + const stub = newStub({ + offered: false, + saved: { + workspace_name: { value: 'Tenant Org', source: 'tenant' }, + logo_url: { value: 'https://tenant.test/logo.svg', source: 'tenant' }, + accent_color: { value: '#abcdef', source: 'tenant' }, + }, + }); + const { serve } = await mount({ settings: settingsService(stub) }); + expect(await serve()).toMatchObject({ + productName: 'Env Product', logoUrl: 'https://env.test/logo.svg', brandColor: '#111111', + }); + expect(stub.calls).toEqual([]); + }); + }); + + it('with no settings service the deployment branding is served, as before', async () => { + const { serve } = await mount(); + expect(await serve()).toMatchObject({ + productName: 'Env Product', logoUrl: 'https://env.test/logo.svg', brandColor: '#111111', + productShortName: 'Env Product', pwaThemeColor: '#111111', + }); + }); +}); From 73adb744f9e603a537ea9256aa9f1bf99097794d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:03:37 +0000 Subject: [PATCH 03/11] wip: changesets for the branding settings and runtime-config wiring Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24766-branding-settings-v2.md | 31 +++++++++++++++++++ .../24766-runtime-config-branding-settings.md | 17 ++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/24766-branding-settings-v2.md create mode 100644 .changeset/24766-runtime-config-branding-settings.md diff --git a/.changeset/24766-branding-settings-v2.md b/.changeset/24766-branding-settings-v2.md new file mode 100644 index 00000000000..3e9b8b94efc --- /dev/null +++ b/.changeset/24766-branding-settings-v2.md @@ -0,0 +1,31 @@ +--- +'@objectstack/service-settings': minor +--- + +fix(service-settings)!: the Branding settings drive the console's name, logo and colour on a single-organization deployment, retire `theme_mode` and `support_email`, and are not offered under a walled tenancy posture + +Clause-②: no (narrowing) + + + +Setup → Branding saved values that nothing in the console ever read. Under the maintainer's ruling, its three keys now drive the console's own branding through the runtime config (`@objectstack/cloud-connection`, same release), the two keys with no counterpart retire, and the page is offered only where no organization wall stands. + +**What the three keys do now** (a single-organization deployment, the `single` posture): + +- `workspace_name` is the product name in the console, on the sign-in page and in the browser title, and it stays the product name in sign-in emails, the authenticator app and MCP sign-in. +- `logo_url` is the logo on the sign-in page, the loading screen and the home header. +- `accent_color` is the browser and installed-app theme colour. Each app keeps the colours its own `AppBranding` declares. +- A key nobody saved falls back to the deployment's own branding (`OS_PRODUCT_NAME`, `OS_LOGO_URL`, `OS_BRAND_COLOR`, or the host's `RuntimeConfigPlugin` options). A save shows on the next page load, with no restart. `accent_color` no longer declares the `#6366f1` default, which the page showed as a colour the console never used. + +**BREAKING: `theme_mode` and `support_email` are retired.** The Branding manifest (`brandingSettingsManifest`) drops both specifiers and their copy in the `en`, `zh-CN`, `ja-JP` and `es-ES` settings bundles, and its `version` is now 2, as the `SettingsManifest.version` contract asks when keys are removed. Nothing ever read either key. A value a workspace already stored is kept: + +- The `sys_setting` row stays exactly as it was. No read or save rewrites or deletes it, and it never blocks saving the live keys. +- `GET /api/settings/branding` does not resolve it: the key is absent from both `manifest.specifiers` and `values`. +- A `PUT /api/settings/branding` that names `theme_mode` or `support_email` is refused with `400 UNKNOWN_KEY` (`details.key` names it). The refusal covers the whole batch, so a live key sent beside it does not land either. +- `OS_BRANDING_THEME_MODE` and `OS_BRANDING_SUPPORT_EMAIL` are no longer read. + +**BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, and `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's, through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. + +**What to do after upgrading.** Nothing has to be rewritten. There is no replacement for `theme_mode` (the console follows the user's own theme choice) or for `support_email`. On a walled deployment, set the deployment's branding with the three `OS_*` variables. + +It ships as `minor` under the launch-window convention for narrowings. diff --git a/.changeset/24766-runtime-config-branding-settings.md b/.changeset/24766-runtime-config-branding-settings.md new file mode 100644 index 00000000000..972d90f08c7 --- /dev/null +++ b/.changeset/24766-runtime-config-branding-settings.md @@ -0,0 +1,17 @@ +--- +'@objectstack/cloud-connection': minor +--- + +feat(cloud-connection): `GET /api/v1/runtime/config` serves the Branding settings' name, logo and colour, falling back to `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR` + +Clause-②: no (the served `branding` block keeps its shape and no exported type changes; only where its values come from moves) + +`RuntimeConfigPlugin` reads the `branding` settings namespace on every request, so Setup → Branding changes the console and the sign-in page on their next load: + +- `branding.productName` ← `branding.workspace_name` +- `branding.logoUrl` ← `branding.logo_url` +- `branding.brandColor` ← `branding.accent_color` + +A value somebody set wins (saved in Setup, or pinned by the settings layer's own `OS_BRANDING_*` variable). A key nobody set falls back exactly as before: the host's constructor option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`, then the built-in default. `productShortName`, `pwaDescription` and `pwaThemeColor` follow the name and colour actually served, unless configured themselves. `brandColor` keeps its meaning: the console's browser and installed-app theme colour, beside each app's own `AppBranding` colours. + +The door reads the settings only when the settings service lists the `branding` namespace. `@objectstack/service-settings` withholds it under the `group` and `isolated` tenancy postures, so on a walled deployment no tenant data reaches this anonymous door and the deployment's `OS_*` branding is served, as before. A settings read that fails serves the deployment's branding and logs one warning; the door never answers 500 for it. From 8f53588c5c93096e247ac5792c82685ebdcf6000 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:53:11 +0000 Subject: [PATCH 04/11] wip: Branding Setup entry moves with the namespace; plugin-auth binds the brand only where branding is offered Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- packages/cli/scripts/check-app-nav-i18n.mjs | 35 ++++++++++++ .../src/apps/setup-nav.contributions.ts | 4 +- .../plugin-auth/src/auth-plugin.test.ts | 47 +++++++++++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 36 ++++++++++-- .../src/settings-branding-posture.pin.test.ts | 51 ++++++++++++++++- .../src/settings-service-plugin.ts | 55 +++++++++++++++++++ 6 files changed, 219 insertions(+), 9 deletions(-) diff --git a/packages/cli/scripts/check-app-nav-i18n.mjs b/packages/cli/scripts/check-app-nav-i18n.mjs index 8e3f5a28d40..23f2ff59422 100644 --- a/packages/cli/scripts/check-app-nav-i18n.mjs +++ b/packages/cli/scripts/check-app-nav-i18n.mjs @@ -368,6 +368,41 @@ const CONTRIBUTORS = [ return { plugin: new DatasourceAdminServicePlugin({}) }; }, }, + { + // `nav_settings_branding` (#24766). The plugin contributes it from its + // `kernel:ready` hook, and only where no organization wall stands, so this + // entry boots it through init → start → kernel:ready on a context of its + // own that captures `kernel:ready` — the shared context below fires no + // hooks — with no `tenancy` service, which is the `single` reading, and + // returns what it registered. + source: '@objectstack/service-settings (Setup entries of the single-posture namespaces)', + apps: ['setup'], + async load() { + const { SettingsServicePlugin } = await import('@objectstack/service-settings'); + const manifests = []; + const ready = []; + const own = { + getService: (name) => { + if (name === 'manifest') return { register: (m) => manifests.push(m) }; + throw new Error(`Service '${name}' not found`); + }, + registerService: () => {}, + registerServiceFactory: () => {}, + replaceService: () => {}, + getServiceScoped: async () => undefined, + getServices: () => new Map(), + hook: (name, fn) => { if (name === 'kernel:ready') ready.push(fn); }, + trigger: async () => {}, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getKernel: () => undefined, + }; + const plugin = new SettingsServicePlugin({ env: {}, registerRoutes: false }); + await plugin.init(own); + await plugin.start(own); + for (const fn of ready) await fn(); + return { manifests }; + }, + }, { source: '@objectstack/mcp (CONNECT_AGENT_UI_BUNDLE)', // The one contributor that lands in BOTH apps: `CONNECT_AGENT_UI_BUNDLE` diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index ff80d367f5d..ad98264e484 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -127,7 +127,9 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // the app's base `setup.access`). { id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, { id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, - { id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, + // `nav_settings_branding` is contributed by `@objectstack/service-settings` + // with the `branding` namespace, which is offered only where no + // organization wall stands (#24766) — so the entry lives and dies with it. { id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index ff1f5315e4d..4b36e1e05ae 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -6,6 +6,7 @@ import { AuthManager } from './auth-manager'; import type { PluginContext } from '@objectstack/core'; import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; +import { SettingsService, brandingSettingsManifest } from '@objectstack/service-settings'; describe('AuthPlugin', () => { let mockContext: PluginContext; @@ -460,12 +461,21 @@ describe('AuthPlugin', () => { let hookCapture: ReturnType; let setAppNameSpy: ReturnType; - const makeSettings = (resolved: { value: unknown; source: string } | Error) => ({ + // `listManifests` lists `branding`, as the settings service does where no + // organization wall stands — the binding is gated on that listing (#24766). + const makeSettings = ( + resolved: { value: unknown; source: string } | Error, + opts: { brandingListed?: boolean } = {}, + ) => ({ get: vi.fn(async () => { if (resolved instanceof Error) throw resolved; return resolved; }), subscribe: vi.fn(), + listManifests: vi.fn(() => [ + { namespace: 'localization' }, + ...(opts.brandingListed === false ? [] : [{ namespace: 'branding' }]), + ]), }); const bootWithSettings = async (settings: unknown) => { @@ -517,6 +527,41 @@ describe('AuthPlugin', () => { expect.stringContaining('failed to apply branding.workspace_name'), ); }); + + // #24766 — under a walled tenancy posture service-settings does not offer + // the `branding` namespace. The binding then stays unbound: no read, no + // subscription, and no `warn` on a boot that is working as designed. + // The double's `get` would THROW for `branding` (as the real service does + // for an unregistered namespace), so a binding that skipped the listing + // check would log the warn this pins away. + it('a walled boot (branding not listed) neither reads it nor warns about it', async () => { + const settings = makeSettings(new Error("Unknown settings namespace: 'branding'"), { brandingListed: false }); + await bootWithSettings(settings); + + expect(settings.get).not.toHaveBeenCalledWith('branding', 'workspace_name', expect.anything()); + expect(settings.subscribe).not.toHaveBeenCalledWith('branding', expect.any(Function)); + const warns = (mockContext.logger.warn as ReturnType).mock.calls.map((c) => String(c[0])); + expect(warns.filter((w) => w.includes('branding'))).toEqual([]); + expect(setAppNameSpy).not.toHaveBeenCalled(); + }); + + // The same two postures through the REAL settings service: withheld under a + // wall (the manifest never registered, so a read would throw + // UnknownNamespaceError), offered and saved under `single`. + it('real SettingsService: no branding registered → unbound and silent; registered and saved → applied', async () => { + const walled = new SettingsService({ env: {} }); + await bootWithSettings(walled); + const warns = (mockContext.logger.warn as ReturnType).mock.calls.map((c) => String(c[0])); + expect(warns.filter((w) => w.includes('branding'))).toEqual([]); + expect(setAppNameSpy).not.toHaveBeenCalled(); + setAppNameSpy.mockRestore(); + + const single = new SettingsService({ env: {} }); + single.registerManifest(brandingSettingsManifest); + await single.set('branding', 'workspace_name', 'Acme Single'); + await bootWithSettings(single); + expect(setAppNameSpy).toHaveBeenCalledWith('Acme Single'); + }); }); /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index e1313b25475..9f496bd8c73 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1112,12 +1112,36 @@ export class AuthPlugin implements Plugin { ); } }; - await applyBrand(); - if (typeof settings.subscribe === 'function') { - settings.subscribe('branding', () => { - void applyBrand(); - }); - ctx.logger.info('Auth: bound appName to settings namespace=branding'); + // [#24766] Bound only where the settings service LISTS the + // `branding` namespace — the test the runtime-config door makes. + // Under a walled tenancy posture service-settings withholds it + // (`UNWALLED_ONLY_NAMESPACES`): there is no value to bind, and + // the read would fail into the warn above on every correct walled + // boot. The configured `appName` (e.g. `OS_APP_NAME`) stays in + // force, which is all a walled read of organization-less rows + // ever produced. A service with no listing is treated the same. + let brandingOffered = false; + try { + const listManifests = (settings as { + listManifests?: () => ReadonlyArray<{ namespace?: unknown } | undefined>; + }).listManifests; + brandingOffered = typeof listManifests === 'function' + && listManifests.call(settings).some((m) => m?.namespace === 'branding'); + } catch (err: any) { + // Kept local so the locale bindings below still run. + ctx.logger.warn( + 'Auth: could not list settings namespaces, branding.workspace_name not bound: ' + + (err?.message ?? err), + ); + } + if (brandingOffered) { + await applyBrand(); + if (typeof settings.subscribe === 'function') { + settings.subscribe('branding', () => { + void applyBrand(); + }); + ctx.logger.info('Auth: bound appName to settings namespace=branding'); + } } // #2815 — bind the auth SMS locale to the deployment default diff --git a/packages/services/service-settings/src/settings-branding-posture.pin.test.ts b/packages/services/service-settings/src/settings-branding-posture.pin.test.ts index f0eae7b4073..4229b577598 100644 --- a/packages/services/service-settings/src/settings-branding-posture.pin.test.ts +++ b/packages/services/service-settings/src/settings-branding-posture.pin.test.ts @@ -22,6 +22,8 @@ import { describe, expect, it, vi } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS } from '@objectstack/platform-objects'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { SettingsServicePlugin } from './settings-service-plugin.js'; import type { SettingsService } from './settings-service.js'; @@ -64,6 +66,10 @@ async function boot(tenancy: Tenancy) { throw new Error('tenancy backend unavailable'); }); } + // Every manifest the plugin registers — its own, and the Setup entries it + // contributes with an offered namespace. + const registered: unknown[] = []; + kernel.registerService('manifest', { register: (m: unknown) => { registered.push(m); } }); const getService = kernel.getService.bind(kernel); const registerService = kernel.registerService.bind(kernel); const warnings: string[] = []; @@ -109,7 +115,35 @@ async function boot(tenancy: Tenancy) { const getBranding = () => call('GET /api/settings/:namespace', { method: 'GET', params: { namespace: 'branding' } as any }); - return { service, listed, putBranding, getBranding, warnings }; + return { service, listed, putBranding, getBranding, warnings, setupNavIds: () => mergedSetupNavIds(registered) }; +} + +/** + * The Setup app's navigation as the registry serves it: the shell and + * `@objectstack/platform-objects`' own contributions, plus whatever this boot + * registered, folded by the real `applyNavContributions`. So the static half + * (`SETUP_NAV_CONTRIBUTIONS` no longer carrying the entry) and the contributed + * half are judged together, on the merged tree the console reads. + */ +function mergedSetupNavIds(registered: unknown[]): { ids: string[]; configuration: string[] } { + const engine = new ObjectQL(); + engine.registry.logLevel = 'silent'; + engine.registerApp({ + id: 'com.objectstack.test.setup-shell', namespace: 'sys', version: '0.0.0', type: 'plugin', scope: 'system', + name: 'Setup shell (fixture)', apps: [SETUP_APP], navigationContributions: SETUP_NAV_CONTRIBUTIONS, + }); + for (const m of registered) engine.registerApp(m); + const ids: string[] = []; + const walk = (items: any[] | undefined) => { + for (const item of items ?? []) { + if (item?.id) ids.push(item.id); + walk(item?.children); + } + }; + const navigation = (engine.registry.getApp('setup') as { navigation?: any[] } | undefined)?.navigation; + walk(navigation); + const group = (navigation ?? []).find((item) => item?.id === 'group_configuration'); + return { ids, configuration: (group?.children ?? []).map((item: any) => item?.id) }; } describe('the Branding namespace under a walled posture (#24766)', () => { @@ -137,6 +171,13 @@ describe('the Branding namespace under a walled posture (#24766)', () => { expect(state.status).toBe(404); expect(state.body.error.code).toBe('UNKNOWN_NAMESPACE'); }); + + it('Setup serves no Branding entry, while its sibling settings entries stay', async () => { + const { ids } = (await boot({ kind: 'service', posture })).setupNavIds(); + expect(ids).not.toContain('nav_settings_branding'); + // Control: the fold is real and the Configuration group is populated. + expect(ids).toEqual(expect.arrayContaining(['nav_settings_company', 'nav_settings_localization'])); + }); }); } }); @@ -158,6 +199,14 @@ describe('the Branding namespace where no wall stands (#24766)', () => { expect(read.status).toBe(200); expect(read.body.data.values.workspace_name).toMatchObject({ value: 'Acme', source: 'tenant' }); }); + + it(`${label}: Setup serves the Branding entry exactly once, in Configuration`, async () => { + const { ids, configuration } = (await boot(tenancy)).setupNavIds(); + expect(ids.filter((id) => id === 'nav_settings_branding')).toHaveLength(1); + // In the Configuration group, after the platform-objects entries. + expect(configuration).toContain('nav_settings_branding'); + expect(configuration.indexOf('nav_settings_branding')).toBeGreaterThan(configuration.indexOf('nav_settings_feature_flags')); + }); } }); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index a282064cb1d..f71e677acc4 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -78,6 +78,35 @@ import { settingsBuiltinTranslations } from './translations/index.js'; */ const UNWALLED_ONLY_NAMESPACES: ReadonlySet = new Set(['branding']); +/** + * The Setup entry of each {@link UNWALLED_ONLY_NAMESPACES} member, contributed + * WITH the namespace (#24766, the amendment's "no Setup entry" half). + * + * It used to be a static item of `@objectstack/platform-objects`' + * `SETUP_NAV_CONTRIBUTIONS`, so a walled deployment listed a page the server + * does not serve. It now lives and dies with the capability — the + * `@objectstack/mcp` `connect-ui.ts` pattern (cloud ADR-0009): registered + * through the `manifest` service from the same `kernel:ready` step that offers + * the namespace, and never where the namespace is withheld. The labels stay + * keyed `apps.setup.navigation.` in platform-objects' bundles, which is + * where the console resolves every Setup entry, contributed or static. + * + * A factory, not a shared literal: the nav fold pushes contributed items into + * the merged tree by reference. + */ +const UNWALLED_ONLY_SETUP_NAV: Readonly Record>> = { + branding: () => ({ + id: 'nav_settings_branding', + type: 'url', + label: 'Branding', + url: '/apps/setup/system/settings/branding', + icon: 'palette', + }), +}; + +/** The bundle id the Setup entries register under — distinct from the plugin's own manifest. */ +const UNWALLED_ONLY_SETUP_NAV_BUNDLE_ID = `${SETTINGS_PLUGIN_ID}.setup-nav`; + /** Configuration options for the SettingsServicePlugin. */ export interface SettingsServicePluginOptions { /** @@ -574,6 +603,32 @@ export class SettingsServicePlugin implements Plugin { this.service!.registerManifest(m); this.registerActionHandlers(m.namespace); } + // The Setup entries travel with the namespaces just offered — and only then. + const items = this.heldUntilPostureKnown + .map((m) => UNWALLED_ONLY_SETUP_NAV[m.namespace]?.()) + .filter((item): item is Record => item !== undefined); + if (items.length === 0) return; + let manifestService: { register(m: unknown): void } | undefined; + try { + manifestService = ctx.getService<{ register(m: unknown): void }>('manifest'); + } catch { + // No manifest service (lean kernels, tests): no app registry to contribute to. + return; + } + manifestService?.register?.({ + id: UNWALLED_ONLY_SETUP_NAV_BUNDLE_ID, + namespace: 'sys', + version: SETTINGS_PLUGIN_VERSION, + type: 'plugin', + scope: 'system', + name: 'Settings Service — Setup entries', + description: 'Setup navigation for the settings namespaces offered only where no organization wall stands.', + navigationContributions: [ + // After platform-objects' Configuration entries (priority 100), before + // later contributors' (service-messaging, 120). + { app: 'setup', group: 'group_configuration', priority: 110, items }, + ], + }); } /** From edce0524585c16f796bf7d39808ba4ba549e54e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:02:00 +0000 Subject: [PATCH 05/11] wip: walled-boot pin double throws only for branding Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-plugin.test.ts | 6 +++++- .../service-settings/src/settings-service-plugin.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 4b36e1e05ae..f41e4aae1d4 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -535,7 +535,11 @@ describe('AuthPlugin', () => { // for an unregistered namespace), so a binding that skipped the listing // check would log the warn this pins away. it('a walled boot (branding not listed) neither reads it nor warns about it', async () => { - const settings = makeSettings(new Error("Unknown settings namespace: 'branding'"), { brandingListed: false }); + const settings = makeSettings({ value: undefined, source: 'default' }, { brandingListed: false }); + settings.get.mockImplementation(async (namespace: string) => { + if (namespace === 'branding') throw new Error("Unknown settings namespace: 'branding'"); + return { value: undefined, source: 'default' }; + }); await bootWithSettings(settings); expect(settings.get).not.toHaveBeenCalledWith('branding', 'workspace_name', expect.anything()); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index f71e677acc4..171c958f570 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -68,7 +68,8 @@ import { settingsBuiltinTranslations } from './translations/index.js'; * "Not offered" is the registry's own answer, so every door agrees without a * second rule: the namespace is absent from `GET /api/settings`, a read or * write of it is refused `404 UNKNOWN_NAMESPACE` by the path every unknown - * namespace takes, and the runtime-config door finds nothing to read. + * namespace takes, and the runtime-config door finds nothing to read. Its + * Setup entry travels with it ({@link UNWALLED_ONLY_SETUP_NAV}). * * Such a manifest is held back at `init()` and registered from the * `kernel:ready` hook once the posture IN FORCE is known: during `init()` the From 09a53e7e86c0c9eabb4b2f3938588b867fe24025 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:12:27 +0000 Subject: [PATCH 06/11] test(plugin-auth): type the brand-binding settings double's arguments (TS2345 in check:test-typecheck) Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-plugin.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index f41e4aae1d4..7ff191d5338 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -467,7 +467,7 @@ describe('AuthPlugin', () => { resolved: { value: unknown; source: string } | Error, opts: { brandingListed?: boolean } = {}, ) => ({ - get: vi.fn(async () => { + get: vi.fn(async (_namespace?: string, _key?: string, _ctx?: unknown) => { if (resolved instanceof Error) throw resolved; return resolved; }), @@ -536,7 +536,7 @@ describe('AuthPlugin', () => { // check would log the warn this pins away. it('a walled boot (branding not listed) neither reads it nor warns about it', async () => { const settings = makeSettings({ value: undefined, source: 'default' }, { brandingListed: false }); - settings.get.mockImplementation(async (namespace: string) => { + settings.get.mockImplementation(async (namespace?: string) => { if (namespace === 'branding') throw new Error("Unknown settings namespace: 'branding'"); return { value: undefined, source: 'default' }; }); From 30fb515b1d1b14ad0a86664faf522249c082d184 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:21:00 +0000 Subject: [PATCH 07/11] wip: changesets for the Setup entry move and the plugin-auth brand binding Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24766-auth-brand-binding.md | 11 +++++++++++ .changeset/24766-branding-settings-v2.md | 2 +- .changeset/24766-setup-branding-entry.md | 11 +++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/24766-auth-brand-binding.md create mode 100644 .changeset/24766-setup-branding-entry.md diff --git a/.changeset/24766-auth-brand-binding.md b/.changeset/24766-auth-brand-binding.md new file mode 100644 index 00000000000..42094aa67a7 --- /dev/null +++ b/.changeset/24766-auth-brand-binding.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-auth': minor +--- + +fix(plugin-auth): the auth product name binds to `branding.workspace_name` only where the settings service offers the `branding` namespace, so a walled boot no longer warns about it + +Clause-②: no (a boot-time binding is skipped where the namespace it reads does not exist; no accept set or served shape moves) + +`AuthPlugin` binds the product name in sign-in emails, the authenticator app entry and the MCP sign-in to the `branding.workspace_name` setting. `@objectstack/service-settings` (same release) no longer offers the `branding` namespace under the `group` and `isolated` tenancy postures, so that read failed on every such boot and logged `Auth: failed to apply branding.workspace_name: No settings manifest registered for namespace 'branding'.` at `warn`. + +The binding is now made only when the settings service lists the `branding` namespace, the same test the runtime config makes. On a walled deployment nothing is read and nothing is logged, and the configured app name (`OS_APP_NAME` and its chain) stays in force. That is the name such a deployment always got, since the walled read only ever saw organization-less rows. On a single-organization deployment a saved name applies as before. diff --git a/.changeset/24766-branding-settings-v2.md b/.changeset/24766-branding-settings-v2.md index 3e9b8b94efc..900e12dd56d 100644 --- a/.changeset/24766-branding-settings-v2.md +++ b/.changeset/24766-branding-settings-v2.md @@ -24,7 +24,7 @@ Setup → Branding saved values that nothing in the console ever read. Under the - A `PUT /api/settings/branding` that names `theme_mode` or `support_email` is refused with `400 UNKNOWN_KEY` (`details.key` names it). The refusal covers the whole batch, so a live key sent beside it does not land either. - `OS_BRANDING_THEME_MODE` and `OS_BRANDING_SUPPORT_EMAIL` are no longer read. -**BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, and `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's, through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. +**BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets, and Setup lists no Branding entry: `SettingsServicePlugin` now contributes the `nav_settings_branding` Setup entry itself, with the namespace, so the entry exists exactly where the page does. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's, through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. **What to do after upgrading.** Nothing has to be rewritten. There is no replacement for `theme_mode` (the console follows the user's own theme choice) or for `support_email`. On a walled deployment, set the deployment's branding with the three `OS_*` variables. diff --git a/.changeset/24766-setup-branding-entry.md b/.changeset/24766-setup-branding-entry.md new file mode 100644 index 00000000000..7f1b44fdf09 --- /dev/null +++ b/.changeset/24766-setup-branding-entry.md @@ -0,0 +1,11 @@ +--- +'@objectstack/platform-objects': minor +--- + +fix(platform-objects): `SETUP_NAV_CONTRIBUTIONS` no longer carries the Setup → Branding entry; `@objectstack/service-settings` contributes it with the `branding` namespace + +Clause-②: no (the entry moves to the package that serves its page: a composition that serves Setup → Branding still lists it, and one that does not no longer lists a page it cannot open) + +The `nav_settings_branding` entry (Setup → Configuration → Branding) was a static item of `SETUP_NAV_CONTRIBUTIONS`, so it was listed on every deployment, including those whose settings service does not offer the `branding` namespace. Under the `group` and `isolated` tenancy postures that namespace is not offered, and the entry opened a page the server refuses with `404 UNKNOWN_NAMESPACE`. + +`SettingsServicePlugin` (`@objectstack/service-settings`, same release) now contributes the entry from the same `kernel:ready` step that offers the namespace, and only then. On a single-organization deployment Setup still lists Branding, now at the end of the Configuration group's platform entries. Its translated labels are unchanged and still keyed `apps.setup.navigation.nav_settings_branding`. From e6522a9572fade10de53501faca4758190ae9b57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:53:36 +0000 Subject: [PATCH 08/11] test(cli): the Connect-an-Agent wire pin counts the static Setup composition (Branding now travels with service-settings) Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../connect-agent-both-halves-wire.pin.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index 69822dd9542..dfece4d7c95 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -252,10 +252,17 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(allowedIds).toContain('nav_connect_agent'); expect(groupChildren(setupAllowed.body?.item, 'group_integrations')) .toContain('nav_connect_agent'); - // The Setup tree the platform admin is served: 9 authored groups less - // `group_approvals`, which nothing contributes into and the filter - // collapses, plus 23 contributed entries plus the MCP one. - expect(allowedIds).toHaveLength(32); + // The Setup tree the platform admin is served BY THIS COMPOSITION: 9 + // authored groups less `group_approvals`, which nothing contributes + // into and the filter collapses, plus the 22 entries + // `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one. + // `nav_settings_branding` is not among them, and not because of a + // tenancy wall (this harness boots none): since #24766 it is + // contributed by `@objectstack/service-settings` together with the + // `branding` namespace, offered only where no organization wall stands, + // and this harness composes platform-objects and the MCP bundle alone. + expect(allowedIds).toHaveLength(31); + expect(allowedIds).not.toContain('nav_settings_branding'); // The two per-user stores are reached from the User record page, not // from Setup's menu (#23803). expect(allowedIds).not.toContain('nav_accounts'); @@ -277,7 +284,8 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - expect(navIds(withAccountHalf.body?.item)).toHaveLength(32); + // 31: the composition the count in the test above states. + expect(navIds(withAccountHalf.body?.item)).toHaveLength(31); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at From 1e59d73adcabc4252ca138a51cdbbfce133d88b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:26:16 +0000 Subject: [PATCH 09/11] test(dogfood): the organization-isolation suite's tenant fixture is company.legal_name, offered under a wall Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- ...ngs-organization-isolation.dogfood.test.ts | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts b/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts index 1091ba9c71e..531e8f552e3 100644 --- a/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/settings-organization-isolation.dogfood.test.ts @@ -30,11 +30,15 @@ * organization) and two organization owners who each create their OWN * organization, so the two tenants carry different active organizations — * asserted, not assumed. - * - namespaces: the shipped `branding` manifest (tenant scope, read - * `setup.access`, write `setup.write`) for 1 and 3; two fixture manifests - * registered on the booted service for 2 and 4, because no shipped manifest - * has a global key an organization may read, or a read capability stricter - * than its write capability. + * - namespaces: the shipped `company` manifest (tenant scope, read + * `setup.access`, write `setup.write`; its `legal_name` key, which declares + * no default) for 1 and 3; two fixture manifests registered on the booted + * service for 2 and 4, because no shipped manifest has a global key an + * organization may read, or a read capability stricter than its write + * capability. ⛔ Not `branding`: since #24766 the settings service does not + * offer that namespace under a walled posture (it answers + * `404 UNKNOWN_NAMESPACE` here), so a suite booting `isolated` cannot use it + * as its tenant-scope fixture. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -132,35 +136,35 @@ describe('settings values are per organization, and the generic read door applie }); it('two organizations each set and read their own tenant-scope value', async () => { - const putA = await settingsCall(tenantAToken, 'PUT', 'branding', { workspace_name: 'Org A Workspace' }); + const putA = await settingsCall(tenantAToken, 'PUT', 'company', { legal_name: 'Org A Ltd' }); expect(putA.status, await putA.clone().text()).toBe(200); // Organization B has written nothing: it does not read A's value. - expect((await readValue(tenantBToken, 'branding', 'workspace_name')).source).toBe('default'); + expect((await readValue(tenantBToken, 'company', 'legal_name')).source).toBe('default'); - const putB = await settingsCall(tenantBToken, 'PUT', 'branding', { workspace_name: 'Org B Workspace' }); + const putB = await settingsCall(tenantBToken, 'PUT', 'company', { legal_name: 'Org B Ltd' }); expect(putB.status, await putB.clone().text()).toBe(200); - expect((await readValue(tenantAToken, 'branding', 'workspace_name')).value).toBe('Org A Workspace'); - expect((await readValue(tenantBToken, 'branding', 'workspace_name')).value).toBe('Org B Workspace'); + expect((await readValue(tenantAToken, 'company', 'legal_name')).value).toBe('Org A Ltd'); + expect((await readValue(tenantBToken, 'company', 'legal_name')).value).toBe('Org B Ltd'); // Two stored rows, each carrying the organization that wrote it. - const rows = (await storedRows('branding')).filter((r) => r.key === 'workspace_name'); + const rows = (await storedRows('company')).filter((r) => r.key === 'legal_name'); expect(rows.map((r) => [r.organization_id, r.value]).sort()).toEqual( - [[orgAId, 'Org A Workspace'], [orgBId, 'Org B Workspace']].sort(), + [[orgAId, 'Org A Ltd'], [orgBId, 'Org B Ltd']].sort(), ); }); it('one organization\'s write, and its reset, leave the other\'s value unchanged', async () => { - const putA = await settingsCall(tenantAToken, 'PUT', 'branding', { workspace_name: 'Org A Renamed' }); + const putA = await settingsCall(tenantAToken, 'PUT', 'company', { legal_name: 'Org A Renamed Ltd' }); expect(putA.status, await putA.clone().text()).toBe(200); - expect((await readValue(tenantBToken, 'branding', 'workspace_name')).value).toBe('Org B Workspace'); + expect((await readValue(tenantBToken, 'company', 'legal_name')).value).toBe('Org B Ltd'); - const reset = await settingsCall(tenantAToken, 'POST', 'branding/reset', {}); + const reset = await settingsCall(tenantAToken, 'POST', 'company/reset', {}); expect(reset.status, await reset.clone().text()).toBe(200); - expect((await readValue(tenantAToken, 'branding', 'workspace_name')).source).toBe('default'); + expect((await readValue(tenantAToken, 'company', 'legal_name')).source).toBe('default'); - const other = await readValue(tenantBToken, 'branding', 'workspace_name'); - expect(other.value).toBe('Org B Workspace'); + const other = await readValue(tenantBToken, 'company', 'legal_name'); + expect(other.value).toBe('Org B Ltd'); expect(other.source).toBe('tenant'); }); @@ -176,20 +180,20 @@ describe('settings values are per organization, and the generic read door applie }); it('a tenant-scope write that names no organization is refused under the walled posture, and writes nothing; the same write from an organization lands', async () => { - const before = (await storedRows('branding')).length; + const before = (await storedRows('company')).length; - const refused = await settingsCall(adminToken, 'PUT', 'branding', { workspace_name: 'No Organization' }); + const refused = await settingsCall(adminToken, 'PUT', 'company', { legal_name: 'No Organization' }); expect(refused.status).toBe(400); const body = (await refused.json()) as ApiError; expect(body.error?.code).toBe('SETTINGS_VALIDATION'); - expect(body.error?.details?.fields?.[0]).toMatchObject({ field: 'workspace_name', code: 'invalid_value' }); - expect((await storedRows('branding')).length).toBe(before); - expect((await storedRows('branding')).some((r) => r.value === 'No Organization')).toBe(false); + expect(body.error?.details?.fields?.[0]).toMatchObject({ field: 'legal_name', code: 'invalid_value' }); + expect((await storedRows('company')).length).toBe(before); + expect((await storedRows('company')).some((r) => r.value === 'No Organization')).toBe(false); // Positive control: the identical write, from inside an organization. - const landed = await settingsCall(tenantAToken, 'PUT', 'branding', { workspace_name: 'No Organization' }); + const landed = await settingsCall(tenantAToken, 'PUT', 'company', { legal_name: 'No Organization' }); expect(landed.status, await landed.clone().text()).toBe(200); - expect((await readValue(tenantAToken, 'branding', 'workspace_name')).value).toBe('No Organization'); + expect((await readValue(tenantAToken, 'company', 'legal_name')).value).toBe('No Organization'); }); it('the generic data-API read of sys_setting withholds a namespace\'s rows from a principal lacking its readPermission', async () => { @@ -214,7 +218,7 @@ describe('settings values are per organization, and the generic read door applie // Positive control 1: the same principal reads its own row of a namespace // whose read capability it holds. - const own = await stack.apiAs(tenantAToken, 'GET', '/data/sys_setting?namespace=branding'); + const own = await stack.apiAs(tenantAToken, 'GET', '/data/sys_setting?namespace=company'); expect(own.status, await own.clone().text()).toBe(200); const ownRows = ((await own.json()) as { data?: Array>; records?: Array> }); expect((ownRows.data ?? ownRows.records ?? []).some((r) => r.organization_id === orgAId)).toBe(true); From acaf3231b8dcaacfa8a7631cb48ed966f8709775 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:33:23 +0000 Subject: [PATCH 10/11] docs: changesets state the walled-deployment FROM/TO for OS_BRANDING_WORKSPACE_NAME; Setup docs name service-settings for Branding Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24766-auth-brand-binding.md | 13 ++++++++++--- .changeset/24766-branding-settings-v2.md | 14 ++++++++++---- .changeset/24766-setup-branding-entry.md | 2 +- content/docs/ui/setup-app.mdx | 2 +- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.changeset/24766-auth-brand-binding.md b/.changeset/24766-auth-brand-binding.md index 42094aa67a7..1b207da830d 100644 --- a/.changeset/24766-auth-brand-binding.md +++ b/.changeset/24766-auth-brand-binding.md @@ -4,8 +4,15 @@ fix(plugin-auth): the auth product name binds to `branding.workspace_name` only where the settings service offers the `branding` namespace, so a walled boot no longer warns about it -Clause-②: no (a boot-time binding is skipped where the namespace it reads does not exist; no accept set or served shape moves) +Clause-②: no (this package stops reading a namespace `@objectstack/service-settings` no longer offers under a wall; that withholding, declared BREAKING in its changeset, is what narrows the walled deployment, and nothing here widens) -`AuthPlugin` binds the product name in sign-in emails, the authenticator app entry and the MCP sign-in to the `branding.workspace_name` setting. `@objectstack/service-settings` (same release) no longer offers the `branding` namespace under the `group` and `isolated` tenancy postures, so that read failed on every such boot and logged `Auth: failed to apply branding.workspace_name: No settings manifest registered for namespace 'branding'.` at `warn`. +`AuthPlugin` binds the product name in sign-in emails, the authenticator app entry and the MCP sign-in to the `branding.workspace_name` setting. `@objectstack/service-settings` (same release) no longer offers the `branding` namespace under the `group` and `isolated` tenancy postures, so on such a deployment that read would fail on every boot and log `Auth: failed to apply branding.workspace_name: No settings manifest registered for namespace 'branding'.` at `warn`. -The binding is now made only when the settings service lists the `branding` namespace, the same test the runtime config makes. On a walled deployment nothing is read and nothing is logged, and the configured app name (`OS_APP_NAME` and its chain) stays in force. That is the name such a deployment always got, since the walled read only ever saw organization-less rows. On a single-organization deployment a saved name applies as before. +The binding is now made only when the settings service lists the `branding` namespace, the same test the runtime config makes. On a single-organization deployment a saved name, or one pinned with `OS_BRANDING_WORKSPACE_NAME`, applies as before. + +**On a walled deployment (`group` / `isolated`) the auth product name now comes from the `OS_APP_NAME` chain alone.** Nothing is read from the `branding` namespace and nothing is logged. Two sources that used to name sign-in emails, the authenticator issuer and the MCP sign-in there stop doing so: + +- `OS_BRANDING_WORKSPACE_NAME`. The settings service served it as an `env` value whatever the posture, and this plugin applied it. +- A `branding.workspace_name` row stored with no organization, for example one saved before the wall stood. + +Migration, FROM → TO: `OS_BRANDING_WORKSPACE_NAME=Acme` → `OS_APP_NAME=Acme`. Set `OS_APP_NAME` to the name the deployment's sign-in emails and authenticator entries should carry. A deployment that set neither source sees no change. diff --git a/.changeset/24766-branding-settings-v2.md b/.changeset/24766-branding-settings-v2.md index 900e12dd56d..e0f988ad2f6 100644 --- a/.changeset/24766-branding-settings-v2.md +++ b/.changeset/24766-branding-settings-v2.md @@ -15,17 +15,23 @@ Setup → Branding saved values that nothing in the console ever read. Under the - `workspace_name` is the product name in the console, on the sign-in page and in the browser title, and it stays the product name in sign-in emails, the authenticator app and MCP sign-in. - `logo_url` is the logo on the sign-in page, the loading screen and the home header. - `accent_color` is the browser and installed-app theme colour. Each app keeps the colours its own `AppBranding` declares. -- A key nobody saved falls back to the deployment's own branding (`OS_PRODUCT_NAME`, `OS_LOGO_URL`, `OS_BRAND_COLOR`, or the host's `RuntimeConfigPlugin` options). A save shows on the next page load, with no restart. `accent_color` no longer declares the `#6366f1` default, which the page showed as a colour the console never used. +- A key nobody set falls back to the deployment's own branding (`OS_PRODUCT_NAME`, `OS_LOGO_URL`, `OS_BRAND_COLOR`, or the host's `RuntimeConfigPlugin` options). A value pinned with the settings layer's `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` or `OS_BRANDING_ACCENT_COLOR` counts as set. A save shows on the next page load, with no restart. `accent_color` no longer declares the `#6366f1` default, which the page showed as a colour the console never used. **BREAKING: `theme_mode` and `support_email` are retired.** The Branding manifest (`brandingSettingsManifest`) drops both specifiers and their copy in the `en`, `zh-CN`, `ja-JP` and `es-ES` settings bundles, and its `version` is now 2, as the `SettingsManifest.version` contract asks when keys are removed. Nothing ever read either key. A value a workspace already stored is kept: - The `sys_setting` row stays exactly as it was. No read or save rewrites or deletes it, and it never blocks saving the live keys. - `GET /api/settings/branding` does not resolve it: the key is absent from both `manifest.specifiers` and `values`. - A `PUT /api/settings/branding` that names `theme_mode` or `support_email` is refused with `400 UNKNOWN_KEY` (`details.key` names it). The refusal covers the whole batch, so a live key sent beside it does not land either. -- `OS_BRANDING_THEME_MODE` and `OS_BRANDING_SUPPORT_EMAIL` are no longer read. +- `OS_BRANDING_THEME_MODE` and `OS_BRANDING_SUPPORT_EMAIL` are no longer read on any deployment. -**BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets, and Setup lists no Branding entry: `SettingsServicePlugin` now contributes the `nav_settings_branding` Setup entry itself, with the namespace, so the entry exists exactly where the page does. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's, through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. +**BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets, and Setup lists no Branding entry: `SettingsServicePlugin` now contributes the `nav_settings_branding` Setup entry itself, with the namespace, so the entry exists exactly where the page does. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's: the console's through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`, and the auth product name through `OS_APP_NAME`. -**What to do after upgrading.** Nothing has to be rewritten. There is no replacement for `theme_mode` (the console follows the user's own theme choice) or for `support_email`. On a walled deployment, set the deployment's branding with the three `OS_*` variables. +**On a walled deployment the `OS_BRANDING_*` variables are no longer read.** With the namespace withheld, none of `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` and `OS_BRANDING_ACCENT_COLOR` reaches a reader there. One of them had an effect before this release: `OS_BRANDING_WORKSPACE_NAME` named sign-in emails, the authenticator issuer and the MCP sign-in (`@objectstack/plugin-auth` applied it as an `env` value whatever the posture). So did a `branding.workspace_name` row stored with no organization. Both now give way to the `OS_APP_NAME` chain. Migration, FROM → TO: `OS_BRANDING_WORKSPACE_NAME=Acme` → `OS_APP_NAME=Acme`. The other two variables had no reader before and have none on a walled deployment now. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. + +**What to do after upgrading.** + +- On a single-organization deployment nothing has to be rewritten. +- There is no replacement for `theme_mode` (the console follows the user's own theme choice) or for `support_email`. +- On a walled deployment, set the console's branding with `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. If you set `OS_BRANDING_WORKSPACE_NAME` there, move its value to `OS_APP_NAME`. It ships as `minor` under the launch-window convention for narrowings. diff --git a/.changeset/24766-setup-branding-entry.md b/.changeset/24766-setup-branding-entry.md index 7f1b44fdf09..809d9916ead 100644 --- a/.changeset/24766-setup-branding-entry.md +++ b/.changeset/24766-setup-branding-entry.md @@ -6,6 +6,6 @@ fix(platform-objects): `SETUP_NAV_CONTRIBUTIONS` no longer carries the Setup → Clause-②: no (the entry moves to the package that serves its page: a composition that serves Setup → Branding still lists it, and one that does not no longer lists a page it cannot open) -The `nav_settings_branding` entry (Setup → Configuration → Branding) was a static item of `SETUP_NAV_CONTRIBUTIONS`, so it was listed on every deployment, including those whose settings service does not offer the `branding` namespace. Under the `group` and `isolated` tenancy postures that namespace is not offered, and the entry opened a page the server refuses with `404 UNKNOWN_NAMESPACE`. +The `nav_settings_branding` entry (Setup → Configuration → Branding) was a static item of `SETUP_NAV_CONTRIBUTIONS`, listed whatever the settings service offered. From this release `@objectstack/service-settings` does not offer the `branding` namespace under the `group` and `isolated` tenancy postures, so a static entry would open a page the server refuses with `404 UNKNOWN_NAMESPACE`. `SettingsServicePlugin` (`@objectstack/service-settings`, same release) now contributes the entry from the same `kernel:ready` step that offers the namespace, and only then. On a single-organization deployment Setup still lists Branding, now at the end of the Configuration group's platform entries. Its translated labels are unchanged and still keyed `apps.setup.navigation.nav_settings_branding`. diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index ea5b2c9f515..a62536caaae 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -52,7 +52,7 @@ anchors are: | **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` | | **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | | **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` | -| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | +| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects`; Branding — `service-settings`, only where the `branding` settings namespace is offered (the `single` tenancy posture) | | **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Log — `plugin-audit` | | **Integrations** (`group_integrations`) | `plugin-webhooks` | | **Advanced** (`group_advanced`) | OAuth Applications — `platform-objects` | From d616d4d873375890e054174bafb82ff0dc53aa8d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:37:44 +0000 Subject: [PATCH 11/11] docs(changeset): the walled-deployment OS_APP_NAME fix stated as an environment setting; the ADR-0087 reasoning says why the ledger carries nothing Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24766-branding-settings-v2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24766-branding-settings-v2.md b/.changeset/24766-branding-settings-v2.md index e0f988ad2f6..9da30c12508 100644 --- a/.changeset/24766-branding-settings-v2.md +++ b/.changeset/24766-branding-settings-v2.md @@ -6,7 +6,7 @@ fix(service-settings)!: the Branding settings drive the console's name, logo and Clause-②: no (narrowing) - + Setup → Branding saved values that nothing in the console ever read. Under the maintainer's ruling, its three keys now drive the console's own branding through the runtime config (`@objectstack/cloud-connection`, same release), the two keys with no counterpart retire, and the page is offered only where no organization wall stands. @@ -26,7 +26,7 @@ Setup → Branding saved values that nothing in the console ever read. Under the **BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets, and Setup lists no Branding entry: `SettingsServicePlugin` now contributes the `nav_settings_branding` Setup entry itself, with the namespace, so the entry exists exactly where the page does. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's: the console's through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`, and the auth product name through `OS_APP_NAME`. -**On a walled deployment the `OS_BRANDING_*` variables are no longer read.** With the namespace withheld, none of `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` and `OS_BRANDING_ACCENT_COLOR` reaches a reader there. One of them had an effect before this release: `OS_BRANDING_WORKSPACE_NAME` named sign-in emails, the authenticator issuer and the MCP sign-in (`@objectstack/plugin-auth` applied it as an `env` value whatever the posture). So did a `branding.workspace_name` row stored with no organization. Both now give way to the `OS_APP_NAME` chain. Migration, FROM → TO: `OS_BRANDING_WORKSPACE_NAME=Acme` → `OS_APP_NAME=Acme`. The other two variables had no reader before and have none on a walled deployment now. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. +**On a walled deployment the `OS_BRANDING_*` variables are no longer read.** With the namespace withheld, none of `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` and `OS_BRANDING_ACCENT_COLOR` reaches a reader there. One of them had an effect before this release: `OS_BRANDING_WORKSPACE_NAME` named sign-in emails, the authenticator issuer and the MCP sign-in (`@objectstack/plugin-auth` applied it as an `env` value whatever the posture). So did a `branding.workspace_name` row stored with no organization. Both now give way to the `OS_APP_NAME` chain. The fix is in the deployment's environment, not in any code or metadata: a walled deployment that set `OS_BRANDING_WORKSPACE_NAME=Acme` sets `OS_APP_NAME=Acme` instead. The other two variables had no reader before and have none on a walled deployment now. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule. **What to do after upgrading.**