Skip to content

Commit 40cf959

Browse files
committed
wip(service-settings): branding manifest v2, posture-gated namespace, pins
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent feb09db commit 40cf959

12 files changed

Lines changed: 774 additions & 133 deletions

File tree

‎packages/cloud-connection/src/runtime-config-plugin.ts‎

Lines changed: 186 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,40 @@
4545
* Host option only, no env var: the value belongs to the distribution whose
4646
* control plane serves the page, and the cloud subclass fills it.
4747
*
48+
* ## `branding` — the Branding settings, then the deployment's own (#24766)
49+
*
50+
* Three fields read the `branding` settings namespace on EVERY request, so a
51+
* save in Setup → Branding shows on the console's and the sign-in page's next
52+
* load (the maintainer's ruling B′):
53+
*
54+
* productName ← branding.workspace_name
55+
* logoUrl ← branding.logo_url
56+
* brandColor ← branding.accent_color
57+
*
58+
* A value somebody set (any `source` but `'default'`: saved in Setup, or
59+
* pinned by the settings layer's own `OS_BRANDING_*` override) wins. A key
60+
* nobody set falls back to the deployment's branding exactly as before — the
61+
* host's constructor option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` /
62+
* `OS_BRAND_COLOR`, then the built-in default — so the settings layer's
63+
* manifest default is never served. The derived fields follow the served
64+
* values unless configured themselves: `productShortName` and `pwaDescription`
65+
* the name, `pwaThemeColor` the colour. `brandColor` keeps its one meaning:
66+
* the console's browser and installed-app theme colour, beside each app's own
67+
* `AppBranding` colours, with no precedence between them.
68+
*
69+
* ⛔ No tenant data reaches this anonymous door under a walled posture. The
70+
* settings service offers the namespace only where no organization wall
71+
* stands (`service-settings`' `UNWALLED_ONLY_NAMESPACES`), and this door reads
72+
* it only when the service lists it — absent, there is nothing to read and the
73+
* deployment's branding is all that is served. Present, the read names no
74+
* organization, which under `single` resolves the deployment's one
75+
* organization, the same read `plugin-auth` makes for `workspace_name`. No
76+
* organization is resolved from the request before sign-in.
77+
*
78+
* A read that fails is not this door's failure: the deployment's branding is
79+
* served and one `warn` names the cause, so the sign-in page never 500s over a
80+
* settings outage.
81+
*
4882
* ## `branding.stage` — a documented knob that this runtime never sent (#9252)
4983
*
5084
* The Console's `PreviewBadge` reads `branding.stage` to decide whether to show
@@ -243,6 +277,38 @@ interface EnvRegistrySurface {
243277
resolveHostname?(hostname: string): Promise<any>;
244278
}
245279

280+
/** The settings namespace three `branding` fields read (#24766). */
281+
const BRANDING_SETTINGS_NAMESPACE = 'branding';
282+
283+
/** Which `branding` settings key each served field reads (#24766, ruling B′). */
284+
const BRANDING_SETTINGS_KEYS = {
285+
productName: 'workspace_name',
286+
logoUrl: 'logo_url',
287+
brandColor: 'accent_color',
288+
} as const;
289+
290+
type SettingsBrandedField = keyof typeof BRANDING_SETTINGS_KEYS;
291+
292+
/** The served fields a saved Branding setting supplied, each only when one was set. */
293+
type SavedBranding = Partial<Record<SettingsBrandedField, string>>;
294+
295+
/**
296+
* The slice of the `settings` service this door reads. Declared structurally,
297+
* as `plugin-auth` declares its own: this package does not depend on
298+
* `@objectstack/service-settings`, and a host may register any settings
299+
* service. `listManifests` is how the door learns whether the namespace is
300+
* offered at all; a service without it is treated as not offering it, which is
301+
* the direction that serves no tenant data.
302+
*/
303+
interface BrandingSettingsSurface {
304+
listManifests?(): ReadonlyArray<{ namespace?: unknown; specifiers?: ReadonlyArray<{ key?: unknown }> }>;
305+
getMany?(
306+
namespace: string,
307+
keys: readonly string[],
308+
ctx?: Record<string, unknown>,
309+
): Promise<Record<string, { value?: unknown; source?: unknown } | undefined>>;
310+
}
311+
246312
/**
247313
* The marketplace HTTP namespace, and the one sub-path inside it that is NOT
248314
* a browse surface.
@@ -580,7 +646,8 @@ export interface RuntimeConfigPluginConfig {
580646
* Product name shown in browser title, splash screen, and other
581647
* client chrome. Operators can override per-deployment (white-label,
582648
* regional rebrands). Falls back to `OS_PRODUCT_NAME` env var, then
583-
* to the default `'ObjectOS'`.
649+
* to the default `'ObjectOS'`. A saved `branding.workspace_name`
650+
* setting outranks all three, read per request (#24766).
584651
*/
585652
productName?: string;
586653
/** Short product name (PWA shortName, compact spots). Defaults to productName. */
@@ -602,11 +669,17 @@ export interface RuntimeConfigPluginConfig {
602669
* refused and warned about at mount time — never forwarded.
603670
*/
604671
stage?: PlatformStage;
605-
/** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */
672+
/**
673+
* Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var.
674+
* A saved `branding.logo_url` setting outranks both, read per request (#24766).
675+
*/
606676
logoUrl?: string;
607677
/** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */
608678
faviconUrl?: string;
609-
/** Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var. */
679+
/**
680+
* Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var.
681+
* A saved `branding.accent_color` setting outranks both, read per request (#24766).
682+
*/
610683
brandColor?: string;
611684
/** PWA manifest description. Falls back to OS_PWA_DESCRIPTION env var. Default: "<productName> — runtime console". */
612685
pwaDescription?: string;
@@ -679,8 +752,16 @@ export class RuntimeConfigPlugin implements Plugin {
679752
private readonly installLocalCeiling: boolean;
680753
private readonly aiStudio: boolean;
681754
private readonly singleEnvironment: boolean;
755+
/**
756+
* The deployment's product name — host option, `OS_PRODUCT_NAME`, then
757+
* `'ObjectOS'`. Served when no Branding setting names one (#24766).
758+
*/
682759
private readonly productName: string;
683-
private readonly productShortName: string;
760+
/**
761+
* The configured short name (host option or `OS_PRODUCT_SHORT_NAME`), or
762+
* `undefined` to derive it from the product name actually served.
763+
*/
764+
private readonly configuredShortName: string | undefined;
684765
/** Resolved stage, or `undefined` for "send no key" (unset or refused). */
685766
private readonly stage: PlatformStage | undefined;
686767
/**
@@ -695,11 +776,20 @@ export class RuntimeConfigPlugin implements Plugin {
695776
private readonly upgradeUrl: string | undefined;
696777
/** The refused spelling, kept so `start()` can name it once — same reason as `refusedStage`. */
697778
private readonly refusedUpgradeUrl: string | undefined;
779+
/** The deployment's logo (host option, then `OS_LOGO_URL`); a Branding setting outranks it. */
698780
private readonly logoUrl: string | undefined;
699781
private readonly faviconUrl: string | undefined;
782+
/** The deployment's brand colour (host option, then `OS_BRAND_COLOR`); a Branding setting outranks it. */
700783
private readonly brandColor: string | undefined;
701-
private readonly pwaDescription: string;
702-
private readonly pwaThemeColor: string;
784+
/** Configured (host option or env), or `undefined` to derive from the served product name. */
785+
private readonly configuredPwaDescription: string | undefined;
786+
/** Configured (host option or env), or `undefined` to derive from the served brand colour. */
787+
private readonly configuredPwaThemeColor: string | undefined;
788+
/**
789+
* Whether the last Branding settings read failed and was reported — one
790+
* `warn` per run of failures, not one per request.
791+
*/
792+
private reportedBrandingReadFailure = false;
703793
/**
704794
* The resolved sink served as `telemetry.errorReporting`, or `undefined`
705795
* for "serve no block" — unset, refused, or lowered by the posture.
@@ -727,7 +817,7 @@ export class RuntimeConfigPlugin implements Plugin {
727817
const envName = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_NAME : undefined)?.trim();
728818
const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim();
729819
this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS';
730-
this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName;
820+
this.configuredShortName = config.productShortName ?? envShort;
731821
// Same precedence as every branding key above — the HOST's explicit
732822
// option wins, the env var is the operator's fallback — but resolved
733823
// through the closed set, so an unrecognised spelling from either door
@@ -758,8 +848,10 @@ export class RuntimeConfigPlugin implements Plugin {
758848
this.logoUrl = config.logoUrl ?? envLogoUrl;
759849
this.faviconUrl = config.faviconUrl ?? envFaviconUrl;
760850
this.brandColor = config.brandColor ?? envBrandColor;
761-
this.pwaDescription = config.pwaDescription ?? envPwaDescription ?? `${this.productName} — runtime console`;
762-
this.pwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor ?? this.brandColor ?? '#4f46e5';
851+
// The two derived defaults are computed per request (`brandingBlock`),
852+
// from the name and colour actually served (#24766).
853+
this.configuredPwaDescription = config.pwaDescription ?? envPwaDescription;
854+
this.configuredPwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor;
763855

764856
// Client error reporting (#12681). Same precedence as every branding
765857
// key above — the HOST's explicit option wins, the env var is the
@@ -992,22 +1084,9 @@ export class RuntimeConfigPlugin implements Plugin {
9921084
// aiStudio + autoPublishAiBuilds + any distribution keys.
9931085
...features,
9941086
},
995-
branding: {
996-
productName: this.productName,
997-
productShortName: this.productShortName,
998-
// Spread, not `stage: this.stage` — the sibling keys
999-
// below may serialize as `undefined` (JSON.stringify
1000-
// drops them) but this one is asserted on by KEY
1001-
// PRESENCE, so it must never exist as a
1002-
// present-and-undefined property on the object handed
1003-
// to a non-JSON consumer or a test.
1004-
...(this.stage !== undefined ? { stage: this.stage } : {}),
1005-
logoUrl: this.logoUrl,
1006-
faviconUrl: this.faviconUrl,
1007-
brandColor: this.brandColor,
1008-
pwaDescription: this.pwaDescription,
1009-
pwaThemeColor: this.pwaThemeColor,
1010-
},
1087+
// Read per request (#24766): a Branding save shows on the
1088+
// next load, with no restart.
1089+
branding: this.brandingBlock(await this.readSavedBranding(ctx)),
10111090
// Its OWN namespace, deliberately not a member of
10121091
// `features` (#12681). That map is open-ended and a host's
10131092
// `resolveFeatures` hook merges arbitrary keys into it
@@ -1037,4 +1116,86 @@ export class RuntimeConfigPlugin implements Plugin {
10371116
rawApp.get('/api/v1/studio/runtime-config', handler);
10381117
});
10391118
};
1119+
1120+
/**
1121+
* The served `branding` block: each settings-backed field from `saved` when
1122+
* a Branding setting supplied it, else the deployment's own value; the
1123+
* derived fields follow what is served unless configured themselves.
1124+
*/
1125+
private brandingBlock(saved: SavedBranding) {
1126+
const productName = saved.productName ?? this.productName;
1127+
const brandColor = saved.brandColor ?? this.brandColor;
1128+
return {
1129+
productName,
1130+
productShortName: (this.configuredShortName ?? productName).trim() || productName,
1131+
// Spread, not `stage: this.stage` — the sibling keys
1132+
// below may serialize as `undefined` (JSON.stringify
1133+
// drops them) but this one is asserted on by KEY
1134+
// PRESENCE, so it must never exist as a
1135+
// present-and-undefined property on the object handed
1136+
// to a non-JSON consumer or a test.
1137+
...(this.stage !== undefined ? { stage: this.stage } : {}),
1138+
logoUrl: saved.logoUrl ?? this.logoUrl,
1139+
faviconUrl: this.faviconUrl,
1140+
brandColor,
1141+
pwaDescription: this.configuredPwaDescription ?? `${productName} — runtime console`,
1142+
pwaThemeColor: this.configuredPwaThemeColor ?? brandColor ?? '#4f46e5',
1143+
};
1144+
}
1145+
1146+
/**
1147+
* The Branding settings somebody set, for this request (#24766).
1148+
*
1149+
* Empty — the deployment's branding is served — when there is no `settings`
1150+
* service, when it does not list the `branding` namespace (it withholds it
1151+
* under a walled posture, so nothing tenant-owned can reach this anonymous
1152+
* door), when no key was set (`source: 'default'`: the manifest default is
1153+
* the settings page's, never this door's), and when the read fails. Only
1154+
* the keys the registered manifest declares are asked for: `getMany`
1155+
* refuses a whole batch over one undeclared key.
1156+
*
1157+
* The read names no organization. Under `single` that resolves the one
1158+
* organization's row; it is the read `plugin-auth` already makes for
1159+
* `workspace_name`.
1160+
*/
1161+
private async readSavedBranding(ctx: PluginContext): Promise<SavedBranding> {
1162+
let settings: BrandingSettingsSurface | undefined;
1163+
try { settings = ctx.getService<BrandingSettingsSurface>('settings'); } catch { return {}; }
1164+
if (typeof settings?.listManifests !== 'function' || typeof settings.getMany !== 'function') return {};
1165+
try {
1166+
const manifest = settings.listManifests().find((m) => m?.namespace === BRANDING_SETTINGS_NAMESPACE);
1167+
if (!manifest) return {};
1168+
const declared = new Set((manifest.specifiers ?? []).map((s) => s?.key));
1169+
const fields = (Object.keys(BRANDING_SETTINGS_KEYS) as SettingsBrandedField[])
1170+
.filter((field) => declared.has(BRANDING_SETTINGS_KEYS[field]));
1171+
if (fields.length === 0) return {};
1172+
const resolved = await settings.getMany(
1173+
BRANDING_SETTINGS_NAMESPACE,
1174+
fields.map((field) => BRANDING_SETTINGS_KEYS[field]),
1175+
{},
1176+
);
1177+
const saved: SavedBranding = {};
1178+
for (const field of fields) {
1179+
const entry = resolved?.[BRANDING_SETTINGS_KEYS[field]];
1180+
if (!entry || entry.source === 'default') continue;
1181+
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
1182+
if (value !== '') saved[field] = value;
1183+
}
1184+
this.reportedBrandingReadFailure = false;
1185+
return saved;
1186+
} catch (err: any) {
1187+
// A functional degradation, so `warn`: the sign-in page shows the
1188+
// deployment's branding, visibly, and nothing claimed-persisted is
1189+
// lost. Once per run of failures — this door answers every page load.
1190+
if (!this.reportedBrandingReadFailure) {
1191+
this.reportedBrandingReadFailure = true;
1192+
ctx.logger?.warn?.(
1193+
`[RuntimeConfigPlugin] could not read the Branding settings (${err?.message ?? err}) — `
1194+
+ 'runtime/config serves the deployment branding (the host options, OS_PRODUCT_NAME / '
1195+
+ 'OS_LOGO_URL / OS_BRAND_COLOR) until a read succeeds.',
1196+
);
1197+
}
1198+
return {};
1199+
}
1200+
}
10401201
}

‎packages/services/service-settings/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ the following built-in manifests are pre-registered out of the box:
107107
|----------------|-----------------------------|---------------------------------------------|
108108
| `mail` | `EmailServicePlugin` | SMTP / Resend / Postmark + `mail/test` |
109109
| `storage` | `StorageServicePlugin` | Local FS / S3 + encrypted secret + `storage/test` |
110-
| `branding` | (built-in fallback) | Organization name, logo, accent colour |
110+
| `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 |
111111
| `feature_flags`| (built-in fallback) | Opt-in experimental features |
112112

113113
## License
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Branding manifest under the maintainer's ruling B′ (#24766): three keys
5+
* the runtime config serves, two retired keys gone, version 2.
6+
*/
7+
8+
import { describe, it, expect } from 'vitest';
9+
import { SettingsManifestSchema } from '@objectstack/spec/system';
10+
import { brandingSettingsManifest } from './branding.manifest.js';
11+
12+
const specs = brandingSettingsManifest.specifiers as Array<{
13+
type: string;
14+
key?: string;
15+
id?: string;
16+
default?: unknown;
17+
}>;
18+
const byKey = (k: string) => specs.find((s) => s.key === k);
19+
20+
describe('brandingSettingsManifest', () => {
21+
it('parses against SettingsManifestSchema', () => {
22+
expect(() => SettingsManifestSchema.parse(brandingSettingsManifest)).not.toThrow();
23+
});
24+
25+
it('declares namespace=branding, scope=tenant, version=2', () => {
26+
const parsed = SettingsManifestSchema.parse(brandingSettingsManifest);
27+
expect(parsed.namespace).toBe('branding');
28+
expect(parsed.scope).toBe('tenant');
29+
// 2 since two keys left (#24766): the spec's `version` contract is
30+
// "increment when keys are renamed/removed".
31+
expect(parsed.version).toBe(2);
32+
});
33+
34+
it('offers exactly the three keys the runtime config serves, in identity/appearance', () => {
35+
expect(specs.filter((s) => s.key).map((s) => s.key)).toEqual([
36+
'workspace_name', 'accent_color', 'logo_url',
37+
]);
38+
expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).toEqual(['identity', 'appearance']);
39+
});
40+
41+
it('offers neither retired key: theme_mode and support_email had no reader (#24766)', () => {
42+
// Ruling B′: the runtime config has no counterpart for either, so they are
43+
// retired, not implemented. Offering them was a form that saved values with
44+
// no effect.
45+
for (const key of ['theme_mode', 'support_email']) {
46+
expect(byKey(key), `${key} must not be offered`).toBeUndefined();
47+
}
48+
});
49+
50+
it('accent_color declares no default: an unset colour is the deployment\'s, never a manifest value', () => {
51+
// The runtime config serves only a value somebody SET; a manifest default
52+
// would show on the settings page as a colour the console never uses.
53+
expect(byKey('accent_color')).toBeDefined();
54+
expect(byKey('accent_color')!.default).toBeUndefined();
55+
expect(byKey('logo_url')!.default).toBeUndefined();
56+
});
57+
});

0 commit comments

Comments
 (0)