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}
0 commit comments