Console Branding Architecture
Audience: engineers working on the Console UI or Authority branding APIs, and operators customizing per-tenant branding. In one line: Authority stores a tenant-scoped branding record (logo, favicon, title, theme tokens); the Console applies it at runtime, falling back to built-in Stella Ops defaults when no record exists.
1. Purpose
- Provide tenant-aware branding (logo, colors, title) without rebuilding the UI.
- Keep branding changes auditable, deterministic, and offline-friendly.
- Fall back to built-in defaults (
Stella Ops, empty theme tokens) when no tenant record exists, and override per tenant after login.
NOTE (reconciled against
src/): the branding fallback default is hard-coded in the AngularBrandingService(tenantId: 'default',title: 'Stella Ops', emptythemeTokens) — it is not injected fromconfig.json. Authority returns the sameStellaOpsdefault when a tenant has no stored branding record.
2. Scope
- Branding data model and storage in Authority.
- API surface for read/update/preview.
- UI application of theme tokens and assets.
- Offline export/import and audit handling.
Non-goals:
- Arbitrary CSS injection from untrusted sources.
- Runtime font downloads from public CDNs (offline-first constraint).
3. Branding Data Model
Authority stores a tenant-scoped branding record. The persisted shape is the StoredBrandingSettings record in ConsoleBrandingEndpointExtensions.cs:
tenantId(the tenant slug; the record is keyed by slug, not a separatebrandingId)displayName(header title; defaults to the tenant slug when omitted)logoUri(data URI or asset reference)faviconUri(data URI or asset reference)themeTokens(CSS variable map; persisted case-insensitively)updatedBy,updatedAtUtchash(lowercase hex sha256 of the canonical-JSON branding record, viaCanonJson.Canonicalize)
NOTE (reconciled against
src/): there is no separatebrandingId; the record is identified by the tenant slug. The API/storage field names arelogoUri/faviconUri(notlogo/favicon).themeTokensis a single flat CSS-variable map — light/dark/high-contrast are not modelled as separate maps in the persisted record.
Constraints (enforced in Authority UpdateBranding):
- Logo and favicon limited to 256KB each — validated as the string length of the data-URI/reference (
Length > 256 * 1024), not the decoded byte count. - At most 100 theme tokens (
themeTokens.Count > 100is rejected). - Theme tokens restricted to a server-side whitelist of CSS-variable prefixes (no arbitrary CSS); values have
;and}stripped and are capped at 50 characters. See §6 for the exact prefix list and the UI/server divergence.
NOTE (reconciled against
src/): MIME-type restriction (image/svg+xml,image/png,image/jpeg) is not validated server-side. It is a client-only constraint enforced by the file-inputacceptattribute in the branding editor — and the accepted set differs by asset: the logo input acceptsimage/png,image/jpeg,image/svg+xmlwhile the favicon input acceptsimage/x-icon,image/png.
Persistence contract:
- Authority persists branding under
authority.tenants.settings.consoleBranding. - The stored record contains
displayName,logoUri,faviconUri,themeTokens,updatedAtUtc,updatedBy, andhash. GET /console/brandingandGET /console/admin/brandingmust read the persisted record first and only fall back to static defaults when no tenant record exists yet.
4. Configuration Layering
- Built-in defaults — hard-coded in the Angular
BrandingService(Stella Ops, empty theme tokens). (Reconciled: not sourced from/config.json.) - Tenant branding from Authority (
GET /console/branding?tenantId=<slug>) after the active tenant is known. - Session/preview overrides — applied locally by the editor’s live preview (computed signals +
previewColor); not persisted and not round-tripped through the server preview endpoint.
If Authority is unreachable (or the request errors), the BrandingService falls back to the built-in defaults via its catchError path, unless the caller passes fallbackToDefault: false.
5. API Surface
All endpoints are served by Authority (ConsoleBrandingEndpointExtensions.MapConsoleBrandingEndpoints). The /console/admin/branding group requires the ui.admin scope (group-level) and a tenant header; each admin endpoint then layers its own scope, and mutation routes add fresh authentication individually.
Tenant context: the public read endpoint takes a
tenantIdquery parameter; the admin endpoints take the tenant from theX-StellaOps-TenantIdheader (AuthorityHttpHeaders.Tenant).
5.1 Read branding (public)
GET /console/branding?tenantId=<slug>- No authentication / no scopes — this is an intentionally public endpoint so the UI can load theme tokens at runtime before sign-in.
tenantIdquery parameter is required (returns400 tenant_requiredif absent).- Falls back to the
StellaOpsdefault branding when the tenant has no stored record. - Emits a best-effort
authority.console.branding.readaudit event (failures here do not fail the request).
5.2 Read branding with metadata (admin)
GET /console/admin/branding- Group scope
ui.admin+ endpoint scopeauthority:branding.read. - Requires the
X-StellaOps-TenantIdheader (returns400 tenant_header_missingif absent). As a read-only route, it does not require fresh authentication. - Returns
{ branding, metadata }wheremetadatacarriesupdatedAtUtc,updatedBy, andhash. - Emits
authority.admin.branding.read.
- Group scope
5.3 Update branding (admin only)
PUT /console/admin/branding- Group scope
ui.admin+ endpoint scopeauthority:branding.write. - Requires the tenant header and route-level fresh-auth via
RequireFreshAuth(). - Audited via
AuditActions.Authority.UpdateBranding(update_branding) and emitsauthority.admin.branding.update(properties includetenant.idandbranding.hash). - Returns
404 tenant_not_foundwhen the tenant slug does not exist; validation errors return400(too_many_tokens,logo_too_large,favicon_too_large).
- Group scope
5.4 Preview branding (admin)
POST /console/admin/branding/preview- Group scope
ui.admin+ endpoint scopeauthority:branding.read(reconciled: read, notauthority:branding.write). - Returns
{ preview, warnings }— sanitized tokens/assets plus advisory warnings (e.g. “No custom logo specified”) — without persisting. - Emits
authority.admin.branding.preview.
- Group scope
ORPHANED (reconciled against
src/): the serverPOST /console/admin/branding/previewendpoint currently has no UI consumer. The AngularBrandingServiceonly calls the public read, the admin read (fetchAdminBranding), and the update (updateBranding); the editor’s preview is rendered entirely client-side. The endpoint remains available for API clients.
6. UI Application
- Branding service fetches
/console/brandingafter login. - Applies CSS variables on
document.documentElement. - Updates header/logo assets and provides the brand suffix used by the router title strategy.
- Route titles remain owned by Angular route metadata so a late branding refresh cannot collapse the browser title to the bare tenant brand.
- Branding tokens are applied as flat CSS custom properties on
document.documentElement(plus their mapped--color-*aliases). Light/dark mode is a separate subsystem: theThemeServicetogglesdata-theme/data-theme-emphasison the document root forlight/dark/systemmodes — branding tokens are not modelled as per-data-themevariants. (Reconciled: there is no high-contrast theme mode;ThemeServicemodes are light/dark/system only.) - The Theme & Branding page exposes first-class sections for the supported color families:
- Brand:
--theme-brand-* - Surfaces:
--theme-bg-* - Text:
--theme-text-* - Borders and focus edges:
--theme-border-* - Sidebar/navigation:
--theme-sidebar-* - Status chips and warning labels:
--theme-status-* - Severity chips:
--theme-severity-* - Primary buttons and clickable actions:
--theme-action-* - Keyboard focus and selected states:
--theme-focus-*,--theme-selection-*
- Brand:
- Saved tenant tokens are mapped onto the corresponding shared
--color-*design-system variables (viaBrandingService.THEME_TO_COLOR_MAP) so badges, warnings, navigation, actions, and content surfaces change consistently. The service also derives opacity-based accent variants (focus ring, sidebar hover/active, selection, brand-light/muted) from--theme-brand-primarywhen no explicit override is supplied.
RECONCILED — server/UI whitelist divergence (verify before relying on full-family persistence): the client
applyThemeTokenswhitelist accepts ten prefixes —--theme-bg-,--theme-text-,--theme-border-,--theme-brand-,--theme-sidebar-,--theme-status-,--theme-severity-,--theme-action-,--theme-focus-,--theme-selection-. The serverSanitizeThemeTokenswhitelist (Authority) is narrower — only six prefixes:--theme-bg-,--theme-text-,--theme-border-,--theme-brand-,--theme-status-,--theme-focus-. As a result,--theme-sidebar-*,--theme-severity-*,--theme-action-*, and--theme-selection-*tokens render in the live preview but are dropped by Authority on save and will not survive a reload from the persisted record. (FLAGGED as a code-level inconsistency, not a doc-only fix.)
- The editor preview is scenario-based rather than a raw token gallery. Each editor section owns a compact live preview focused on the elements that section changes: identity assets, brand accents, surfaces, text hierarchy, borders, sidebar navigation, status chips, severity chips, actions, focus/selection states, night-mode panels, and custom-token staging.
- The default light surface family is a near-white warm Stella sun palette, with the dark Stella rail preserved for navigation contrast.
7. Audit and Offline
- Every branding endpoint emits an audit event via the Authority
IAuthEventSink:- Public read →
authority.console.branding.read(best-effort) - Admin read →
authority.admin.branding.read - Update →
authority.admin.branding.update(properties:tenant.id,branding.hash) - Preview →
authority.admin.branding.preview
- Public read →
- Updates are additionally
AuditedwithAuditActions.Authority.UpdateBranding(action idupdate_branding).
RECONCILED (event name): there is no
authority.branding.updatedevent; the emitted event isauthority.admin.branding.update(see list above).
NOT IMPLEMENTED (roadmap): “branding bundles exported with a detached signature for offline import” and “Console shows last applied branding hash for verification” are not present in
src/. There is no branding export/import or detached-signature path, and the branding editor does not surface themetadata.hashto operators (the API returns it, but the editor does not render it). Treat these two bullets as forward-looking until implemented. The per-recordhashis computed and persisted today, and is used for canonical-record integrity / cache invalidation rather than an offline bundle.
8. References
- Console operator guide (UI_GUIDE)
- UI / Console module architecture
- Authority module architecture
- Console Admin RBAC — the
authority:branding.read/authority:branding.writescopes and admin surface this page’s endpoints sit within.
