diff --git a/docs/MSP.md b/docs/MSP.md index b0a69e8a0..8c901d33e 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -236,7 +236,13 @@ environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`, model each client runtime has its own settings, so the override is per-client; in shared-process mode the settings override applies instance-wide, so all organizations share one brand (usually yours). Branding -requires the `white_label` entitlement on the licence. +requires the `white_label` entitlement on the licence. Entitled administrators +can edit the settings-based display name and bounded inline PNG, JPEG, or GIF +under **Settings → System → General → Appearance**. That override is used by +both generated reports and the authenticated application header; the browser +title follows the configured display name. Without the entitlement, the +runtime returns and renders the built-in Pulse identity even if branding +settings remain stored. Scheduled reports are tenant-local. In provider-hosted MSP, each client runtime stores its own schedules in `report_schedules.json`, writes generated diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 83736b96d..fd1edb757 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -8711,3 +8711,22 @@ to configure a public URL. `TestContract_SSOProviderResponseBaseURLNeverGuessesL in `internal/api/contract_test.go` pins the precedence, the request fallback, the trusted-proxy gate on forwarded headers, and the empty-rather-than-wrong behavior. + +### Runtime branding is a narrow presentation contract + +`GET /api/runtime/branding` is the authenticated `monitoring:read` boundary +for application-shell branding. It returns only `enabled`, `displayName`, and +the validated inline `logoDataUrl`; it never returns the system settings +document, filesystem logo paths, provider credentials, or licence material. +The handler returns the canonical disabled/empty shape unless the request's +active licence service grants `white_label`, even when `reportBranding` +remains persisted. + +The settings write contract remains `reportBranding` on +`POST /api/system/settings/update`, protected by `settings:write` and the +existing bounded display-name, base64, and PNG/JPEG/GIF validation. The +runtime read normalizes valid image bytes to a canonical data URL and omits an +invalid or unknown image without discarding a valid display name. +`internal/api/runtime_branding_test.go` and +`internal/api/route_inventory_test.go` pin the entitlement, shape, image, and +route contracts. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0333c88c2..39f2b72a4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5502,3 +5502,22 @@ as the card. `settingsArchitecture.test.ts` pins the guidance owner, the absent localhost literal, and the corrected post-save copy; the rendered fallbacks and copy affordances are covered by `frontend-modern/src/components/Settings/__tests__/SSOProvidersPanel.test.tsx`. + +### Entitled application branding stays app-shell-owned + +`frontend-modern/src/stores/systemSettings.ts` owns the narrow reactive +runtime-brand payload loaded during authenticated bootstrap. The shared +`frontend-modern/src/AppLayout.tsx` shell is the only owner of applying that +payload to the centered header lockup and route-aware browser title. A custom +bounded banner logo replaces the built-in mark; a non-empty display name +replaces the Pulse wordmark, while a logo with an empty display name may stand +alone. Kiosk mode keeps its existing hidden-header behavior. + +The Appearance surface edits the already-canonical `reportBranding` object +through `BrandingSettingsCard`; it accepts PNG, JPEG, or GIF files no larger +than the persisted inline-logo boundary, previews the exact saved material, +marks the shared settings form dirty, and provides an explicit remove action. +It must not create a page-local branding cache or render configured values +when `white_label` is unavailable. Focused proofs live in +`BrandingSettingsCard.test.tsx`, `AppLayout.test.tsx`, and +`stores/__tests__/systemSettings.test.ts`. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 6036f76ad..44ced090c 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1789,3 +1789,22 @@ already enforces scopes server-side and no backend behaviour changed. Regression coverage: the `Issue1650` cases in `frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts` and `frontend-modern/src/components/__tests__/SecurityWarning.test.tsx`. + +### Runtime branding reveals presentation material only after entitlement + +The authenticated application header cannot depend on the admin-only full +system-settings response, so `/api/runtime/branding` is deliberately readable +with `monitoring:read`. That wider read authority is safe only because the +payload is a strict allowlist of three presentation fields and the server +fails closed: without the active `white_label` entitlement it returns +`enabled: false` with empty name and logo values. It never exposes +`logoPath`, other settings, environment values, licence records, or storage +locations. + +Brand mutations remain behind `settings:write` and the existing report-brand +validation rejects unsupported keys, newlines, oversized base64, malformed +base64, and formats outside PNG/JPEG/GIF. Browser rendering consumes only the +server-filtered runtime payload; hiding controls or checking a client-side +capability is not treated as the authorization boundary. +`internal/api/runtime_branding_test.go` proves the no-entitlement non-leakage +and image normalization. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 527bb27f4..eae1cc0fc 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -4818,3 +4818,12 @@ only for a session that can reach settings, using the single handoff, demo organization suppression, and platform-page route carriage in `frontend-modern/src/App.tsx` are unchanged; nothing about backup, restore, or recovery routing keys off banner visibility. + +The persisted `reportBranding` object remains adjacent tenant/runtime +configuration rather than storage inventory or recovery state. The +application-header extension reuses that same object and does not create a +second logo artifact, database table, or backup surface. Existing system +configuration export/restore therefore carries the display name and bounded +inline logo exactly as it already carries report branding. Runtime reads +through `/api/runtime/branding` are read-only and entitlement-filtered; they do +not mutate, relocate, or synthesize persisted branding state. diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 9f93a8619..e094b1c00 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -54,6 +54,7 @@ import { getActionApprovalBadgePresentation } from '@/features/actions/actionPre import { actionInboxStore } from '@/stores/actionInbox'; import { patrolAttentionStore } from '@/stores/patrolAttention'; import { isPro } from '@/stores/licenseCommercial'; +import { runtimeBranding } from '@/stores/systemSettings'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { getAssistantPageContext } from '@/utils/assistantPageContext'; import type { AppConnectionStatus } from '@/useAppRuntimeState'; @@ -235,6 +236,13 @@ export function AppLayout(props: AppLayoutProps) { const location = useLocation(); const kioskMode = useKioskMode(); const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy'); + const customBrandLogo = createMemo(() => + runtimeBranding().enabled ? runtimeBranding().logoDataUrl : '', + ); + const customBrandName = createMemo(() => + runtimeBranding().enabled ? runtimeBranding().displayName.trim() : '', + ); + const browserBrandName = createMemo(() => customBrandName() || 'Pulse'); const [headerVisible, setHeaderVisible] = createSignal(true); const [skipLinkFocused, setSkipLinkFocused] = createSignal(false); @@ -290,7 +298,7 @@ export function AppLayout(props: AppLayoutProps) { createEffect(() => { const active = getActiveTabForPath(location.pathname); if (!active) { - document.title = 'Pulse'; + document.title = browserBrandName(); return; } // The standalone (Machines) section has sub-tabs with their own labels. @@ -298,10 +306,10 @@ export function AppLayout(props: AppLayoutProps) { // the actual page (e.g. "Availability checks" not "Machines"). if (active === 'standalone') { const subTab = resolveStandaloneSubTabTitle(location.pathname); - document.title = `${subTab} · Pulse`; + document.title = `${subTab} · ${browserBrandName()}`; return; } - document.title = `${tabTitleByActive[active]} · Pulse`; + document.title = `${tabTitleByActive[active]} · ${browserBrandName()}`; }); const toggleKioskMode = () => { @@ -754,37 +762,55 @@ export function AppLayout(props: AppLayoutProps) {
- + } > - Pulse Logo - - - - - Pulse + {(logoDataUrl) => ( + {customBrandName() + )} + + + + {customBrandName() || 'Pulse'} + + Preview diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index c2fe1923d..dbd21e151 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -11,6 +11,10 @@ import { import { isKioskMode, setKioskMode } from '@/utils/url'; import type { PlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel'; import { aiChatStore } from '@/stores/aiChat'; +import { + clearRuntimeBranding, + updateRuntimeBrandingFromResponse, +} from '@/stores/systemSettings'; HTMLElement.prototype.scrollIntoView = vi.fn(); window.scrollTo = vi.fn(); @@ -117,6 +121,7 @@ describe('AppLayout navigation icons', () => { window.history.replaceState({}, '', '/settings/infrastructure'); resetPrimaryNavigationRouteMemory(); patrolAttentionMockState.activeCount = 0; + clearRuntimeBranding(); aiChatStore.close(); aiChatStore.setEnabled(true); }); @@ -124,6 +129,7 @@ describe('AppLayout navigation icons', () => { afterEach(() => { aiChatStore.close(); aiChatStore.setEnabled(false); + clearRuntimeBranding(); cleanup(); }); @@ -334,6 +340,24 @@ describe('AppLayout navigation icons', () => { expect(container.querySelector('.animate-pulse-logo')).toBeNull(); }); + it('renders entitled custom branding and uses its name in the browser title', () => { + updateRuntimeBrandingFromResponse({ + enabled: true, + displayName: 'Acme Operations', + logoDataUrl: 'data:image/png;base64,YWJj', + }); + + renderLayout(); + + expect(screen.getByTestId('custom-brand-logo')).toHaveAttribute( + 'src', + 'data:image/png;base64,YWJj', + ); + expect(screen.getByText('Acme Operations')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-brand-lockup')).not.toHaveClass('animate-pulse-brand'); + expect(document.title).toBe('Settings · Acme Operations'); + }); + it('keeps the assistant launcher clear of the mobile navigation breakpoint', () => { renderLayout(); diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 45506c025..907db69cb 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -238,6 +238,7 @@ describe('useAppRuntimeState', () => { })); vi.doMock('@/stores/systemSettings', () => ({ + loadRuntimeBranding: vi.fn().mockResolvedValue(undefined), markSystemSettingsLoadedWithDefaults: vi.fn(), updateSystemSettingsFromResponse: vi.fn(), })); diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index f31b75cc0..9c6a9d0ee 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -186,6 +186,22 @@ describe('SettingsAPI', () => { }); }); + describe('getRuntimeBranding', () => { + it('fetches the narrow runtime branding payload', async () => { + const branding = { + enabled: true, + displayName: 'Acme Operations', + logoDataUrl: 'data:image/png;base64,YWJj', + }; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(branding); + + const result = await SettingsAPI.getRuntimeBranding(); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/runtime/branding'); + expect(result).toEqual(branding); + }); + }); + describe('getTelemetryPreview', () => { it('fetches the telemetry preview payload', async () => { const mockPreview = { diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index 44177363c..f97c8be44 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -156,6 +156,12 @@ export interface TelemetryPreviewResponse { payload: TelemetryPingPreview; } +export interface RuntimeBrandingResponse { + enabled: boolean; + displayName: string; + logoDataUrl: string; +} + export class SettingsAPI { private static baseUrl = '/api'; @@ -172,6 +178,10 @@ export class SettingsAPI { return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise; } + static async getRuntimeBranding(): Promise { + return apiFetchJSON(`${this.baseUrl}/runtime/branding`) as Promise; + } + static async getTelemetryPreview(): Promise { return apiFetchJSON( `${this.baseUrl}/system/settings/telemetry-preview`, diff --git a/frontend-modern/src/components/Settings/BrandingSettingsCard.tsx b/frontend-modern/src/components/Settings/BrandingSettingsCard.tsx new file mode 100644 index 000000000..4f9da5299 --- /dev/null +++ b/frontend-modern/src/components/Settings/BrandingSettingsCard.tsx @@ -0,0 +1,198 @@ +import { Show, createSignal, type Accessor, type Component, type Setter } from 'solid-js'; +import ImageIcon from 'lucide-solid/icons/image'; +import Trash2 from 'lucide-solid/icons/trash-2'; +import { Button } from '@/components/shared/Button'; +import { FeatureGateSection } from '@/components/shared/FeatureGateSection'; +import { formControl, formHelpText, formLabel } from '@/components/shared/Form'; +import { hasFeature } from '@/stores/license'; +import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; +import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; +import type { ReportBrandSettings } from '@/types/config'; + +export const BRAND_LOGO_MAX_BYTES = 36 * 1024; + +type BrandLogoFormat = NonNullable; + +export interface BrandingSettingsCardProps { + displayName: Accessor; + setDisplayName: Setter; + logoBase64: Accessor; + setLogoBase64: Setter; + logoFormat: Accessor; + setLogoFormat: Setter; + setHasUnsavedChanges: Setter; +} + +export function brandLogoFormatForFile(file: Pick): BrandLogoFormat | null { + const mime = file.type.toLowerCase(); + if (mime === 'image/png') return 'png'; + if (mime === 'image/jpeg') return 'jpg'; + if (mime === 'image/gif') return 'gif'; + + const extension = file.name.toLowerCase().split('.').pop(); + if (extension === 'png') return 'png'; + if (extension === 'jpg' || extension === 'jpeg') return 'jpg'; + if (extension === 'gif') return 'gif'; + return null; +} + +export function brandingLogoPreview( + logoBase64: string, + logoFormat: BrandLogoFormat, +): string { + const value = logoBase64.trim(); + if (!value) return ''; + if (value.startsWith('data:image/')) return value; + if (!logoFormat) return ''; + const mime = logoFormat === 'jpg' || logoFormat === 'jpeg' ? 'image/jpeg' : `image/${logoFormat}`; + return `data:${mime};base64,${value}`; +} + +function readFileAsDataURL(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read logo file')); + reader.onload = () => + typeof reader.result === 'string' + ? resolve(reader.result) + : reject(new Error('Failed to read logo file')); + reader.readAsDataURL(file); + }); +} + +export const BrandingSettingsCard: Component = (props) => { + const [fileError, setFileError] = createSignal(''); + const preview = () => brandingLogoPreview(props.logoBase64(), props.logoFormat()); + + const markChanged = () => props.setHasUnsavedChanges(true); + + const handleLogoFile = async (file: File | undefined) => { + setFileError(''); + if (!file) return; + + const format = brandLogoFormatForFile(file); + if (!format) { + setFileError('Choose a PNG, JPEG, or GIF image.'); + return; + } + if (file.size > BRAND_LOGO_MAX_BYTES) { + setFileError('Logo files must be 36 KB or smaller.'); + return; + } + + try { + props.setLogoBase64(await readFileAsDataURL(file)); + props.setLogoFormat(format); + markChanged(); + } catch { + setFileError('Pulse could not read that logo file.'); + } + }; + + const clearLogo = () => { + props.setLogoBase64(''); + props.setLogoFormat(''); + setFileError(''); + markChanged(); + }; + + return ( + } + /> + } + > +
+
+
+ + { + props.setDisplayName(event.currentTarget.value); + markChanged(); + }} + /> +

+ Replaces “Pulse” in the page header and browser title. Leave blank to keep the default + name, or to show an uploaded banner by itself. +

+
+ +
+ + void handleLogoFile(event.currentTarget.files?.[0])} + /> +

+ PNG, JPEG, or GIF, up to 36 KB. Transparent or dark-background banner logos work best. +

+ + + +
+
+ +
+
+

Header preview

+
+ + ● + + } + > + {(logo) => ( + + )} + + + + {props.displayName().trim() || 'Pulse'} + + +
+
+ + +
+ +
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 1add70310..4a0999a44 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -7,6 +7,8 @@ import { EnvironmentLockBadge } from '@/components/shared/EnvironmentLockBadge'; import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; import type { TelemetryPreviewResponse } from '@/api/settings'; import { DockerRuntimeSettingsCard } from './DockerRuntimeSettingsCard'; +import { BrandingSettingsCard } from './BrandingSettingsCard'; +import type { ReportBrandSettings } from '@/types/config'; import Sun from 'lucide-solid/icons/sun'; import Moon from 'lucide-solid/icons/moon'; import Languages from 'lucide-solid/icons/languages'; @@ -70,6 +72,12 @@ export interface GeneralSettingsPanelProps { setPVEPollingCustomSeconds: Setter; pvePollingEnvLocked: () => boolean; setHasUnsavedChanges: Setter; + reportBrandDisplayName: Accessor; + setReportBrandDisplayName: Setter; + reportBrandLogoBase64: Accessor; + setReportBrandLogoBase64: Setter; + reportBrandLogoFormat: Accessor>; + setReportBrandLogoFormat: Setter>; telemetryEnabled: Accessor; telemetryEnabledLocked: () => boolean; @@ -212,6 +220,18 @@ export const GeneralSettingsPanel: Component = (props onChange={() => layoutStore.toggle()} />
+ +
+ +
{/* Usage Data + Privacy Card */} diff --git a/frontend-modern/src/components/Settings/__tests__/BrandingSettingsCard.test.tsx b/frontend-modern/src/components/Settings/__tests__/BrandingSettingsCard.test.tsx new file mode 100644 index 000000000..8d6afbede --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/BrandingSettingsCard.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; +import { + BRAND_LOGO_MAX_BYTES, + BrandingSettingsCard, + brandLogoFormatForFile, + brandingLogoPreview, +} from '../BrandingSettingsCard'; + +vi.mock('@/stores/license', () => ({ + hasFeature: () => true, +})); + +vi.mock('@/stores/licenseCommercial', () => ({ + getUpgradeActionDestination: () => ({ href: '/settings/billing', external: false }), +})); + +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyHidesUpgradePrompts: () => false, +})); + +function renderCard() { + const [displayName, setDisplayName] = createSignal(''); + const [logoBase64, setLogoBase64] = createSignal(''); + const [logoFormat, setLogoFormat] = createSignal<'' | 'png' | 'jpg' | 'jpeg' | 'gif'>(''); + const [changed, setChanged] = createSignal(false); + + render(() => ( + + )); + + return { displayName, logoBase64, logoFormat, changed }; +} + +describe('BrandingSettingsCard', () => { + it('normalizes supported image formats and previews plain base64', () => { + expect(brandLogoFormatForFile({ type: 'image/jpeg', name: 'brand.bin' })).toBe('jpg'); + expect(brandLogoFormatForFile({ type: '', name: 'brand.GIF' })).toBe('gif'); + expect(brandLogoFormatForFile({ type: 'image/svg+xml', name: 'brand.svg' })).toBeNull(); + expect(brandingLogoPreview('YWJj', 'png')).toBe('data:image/png;base64,YWJj'); + }); + + it('updates the application name and marks settings dirty', async () => { + const state = renderCard(); + + await fireEvent.input(screen.getByLabelText('Application name'), { + target: { value: 'Acme Operations' }, + }); + + expect(state.displayName()).toBe('Acme Operations'); + expect(state.changed()).toBe(true); + expect(screen.getByText('Acme Operations')).toBeInTheDocument(); + }); + + it('loads a bounded PNG and allows removing it', async () => { + const state = renderCard(); + const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'brand.png', { + type: 'image/png', + }); + + await fireEvent.change(screen.getByLabelText('Header logo'), { + target: { files: [file] }, + }); + + await waitFor(() => expect(state.logoFormat()).toBe('png')); + expect(state.logoBase64()).toMatch(/^data:image\/png;base64,/); + expect(screen.getByTestId('branding-logo-preview')).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: 'Remove logo' })); + expect(state.logoBase64()).toBe(''); + expect(state.logoFormat()).toBe(''); + }); + + it('rejects files larger than the persisted inline-logo boundary', async () => { + const state = renderCard(); + const file = new File([new Uint8Array(BRAND_LOGO_MAX_BYTES + 1)], 'too-large.png', { + type: 'image/png', + }); + + await fireEvent.change(screen.getByLabelText('Header logo'), { + target: { files: [file] }, + }); + + expect(await screen.findByRole('alert')).toHaveTextContent('36 KB or smaller'); + expect(state.logoBase64()).toBe(''); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx index 8f2126447..3f6f32b19 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx @@ -13,6 +13,11 @@ function renderGeneralSettingsPanel(overrides: Partial(''); const props: GeneralSettingsPanelProps = { darkMode: () => false, @@ -26,6 +31,12 @@ function renderGeneralSettingsPanel(overrides: Partial false, setHasUnsavedChanges, + reportBrandDisplayName, + setReportBrandDisplayName, + reportBrandLogoBase64, + setReportBrandLogoBase64, + reportBrandLogoFormat, + setReportBrandLogoFormat, telemetryEnabled, telemetryEnabledLocked: () => false, savingTelemetry: () => false, diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts index 793ca4021..ce567526d 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ updateStoreIsDismissedMock: vi.fn(), updateStoreClearDismissedMock: vi.fn(), updateDockerUpdateActionsSettingMock: vi.fn(), + loadRuntimeBrandingMock: vi.fn(), })); vi.mock('@/api/settings', () => ({ @@ -86,6 +87,7 @@ vi.mock('@/stores/updates', () => ({ vi.mock('@/stores/systemSettings', () => ({ updateDockerUpdateActionsSetting: mocks.updateDockerUpdateActionsSettingMock, + loadRuntimeBranding: mocks.loadRuntimeBrandingMock, })); type HookState = ReturnType; @@ -99,6 +101,7 @@ describe('useSystemSettingsState branch coverage', () => { beforeEach(() => { mocks.getSystemSettingsMock.mockResolvedValue({}); mocks.updateSystemSettingsMock.mockResolvedValue(undefined); + mocks.loadRuntimeBrandingMock.mockResolvedValue(undefined); mocks.getUpdatePlanMock.mockResolvedValue({ canAutoUpdate: false, requiresRoot: false, diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts index e83c4d9d0..bf23b8a10 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts @@ -242,6 +242,7 @@ describe('useSystemSettingsState', () => { vi.doMock('@/stores/systemSettings', () => ({ updateDockerUpdateActionsSetting: vi.fn(), + loadRuntimeBranding: vi.fn().mockResolvedValue(undefined), })); ({ useSystemSettingsState } = await import('../useSystemSettingsState')); diff --git a/frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx b/frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx index 2358789c9..66d938bac 100644 --- a/frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx +++ b/frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx @@ -40,6 +40,12 @@ export function useSettingsSystemPanels( setPVEPollingCustomSeconds: params.systemSettings.setPVEPollingCustomSeconds, pvePollingEnvLocked: params.systemSettings.pvePollingEnvLocked, setHasUnsavedChanges: params.systemSettings.setHasUnsavedChanges, + reportBrandDisplayName: params.systemSettings.reportBrandDisplayName, + setReportBrandDisplayName: params.systemSettings.setReportBrandDisplayName, + reportBrandLogoBase64: params.systemSettings.reportBrandLogoBase64, + setReportBrandLogoBase64: params.systemSettings.setReportBrandLogoBase64, + reportBrandLogoFormat: params.systemSettings.reportBrandLogoFormat, + setReportBrandLogoFormat: params.systemSettings.setReportBrandLogoFormat, telemetryEnabled: params.systemSettings.telemetryEnabled, telemetryEnabledLocked: params.systemSettings.telemetryEnabledLocked, savingTelemetry: params.systemSettings.savingTelemetry, diff --git a/frontend-modern/src/components/Settings/useSystemSettingsState.ts b/frontend-modern/src/components/Settings/useSystemSettingsState.ts index 8c4c8dd2d..3fa87146c 100644 --- a/frontend-modern/src/components/Settings/useSystemSettingsState.ts +++ b/frontend-modern/src/components/Settings/useSystemSettingsState.ts @@ -6,7 +6,11 @@ import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { updateStore } from '@/stores/updates'; import { copyToClipboard } from '@/utils/clipboard'; -import { updateDockerUpdateActionsSetting } from '@/stores/systemSettings'; +import { + loadRuntimeBranding, + updateDockerUpdateActionsSetting, +} from '@/stores/systemSettings'; +import type { ReportBrandSettings } from '@/types/config'; import { BACKUP_INTERVAL_OPTIONS, getCheckForUpdatesErrorMessage, @@ -58,6 +62,10 @@ export function useSystemSettingsState({ const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false); const [savingDockerUpdateActions, setSavingDockerUpdateActions] = createSignal(false); const [telemetryEnabled, setTelemetryEnabled] = createSignal(true); + const [reportBrandDisplayName, setReportBrandDisplayName] = createSignal(''); + const [reportBrandLogoBase64, setReportBrandLogoBase64] = createSignal(''); + const [reportBrandLogoFormat, setReportBrandLogoFormat] = + createSignal>(''); const [savingTelemetry, setSavingTelemetry] = createSignal(false); const [telemetryPreview, setTelemetryPreview] = createSignal( null, @@ -141,6 +149,9 @@ export function useSystemSettingsState({ setHideLocalLogin(systemSettings.hideLocalLogin ?? false); setDisableDockerUpdateActions(systemSettings.disableDockerUpdateActions ?? false); setTelemetryEnabled(systemSettings.telemetryEnabled ?? true); + setReportBrandDisplayName(systemSettings.reportBranding?.displayName ?? ''); + setReportBrandLogoBase64(systemSettings.reportBranding?.logoBase64 ?? ''); + setReportBrandLogoFormat(systemSettings.reportBranding?.logoFormat ?? ''); if (typeof systemSettings.backupPollingEnabled === 'boolean') { setBackupPollingEnabled(systemSettings.backupPollingEnabled); @@ -221,7 +232,13 @@ export function useSystemSettingsState({ allowedEmbedOrigins: allowedEmbedOrigins(), webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(), publicURL: publicURL(), + reportBranding: { + displayName: reportBrandDisplayName().trim(), + logoBase64: reportBrandLogoBase64(), + logoFormat: reportBrandLogoFormat(), + }, }); + await loadRuntimeBranding(); } const isNetworkTab = initiatingTab === 'system-network'; @@ -502,6 +519,12 @@ export function useSystemSettingsState({ savingDockerUpdateActions, handleDisableDockerUpdateActionsChange, telemetryEnabled, + reportBrandDisplayName, + setReportBrandDisplayName, + reportBrandLogoBase64, + setReportBrandLogoBase64, + reportBrandLogoFormat, + setReportBrandLogoFormat, telemetryEnabledLocked, savingTelemetry, handleTelemetryEnabledChange, diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index e16a8c793..0643356ad 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -6,9 +6,12 @@ import { PRIVACY_DOC_URL } from '@/utils/docsLinks'; import { EN_MESSAGES } from '@/i18n/messages'; import { areSystemSettingsLoaded, + clearRuntimeBranding, markSystemSettingsLoadedWithDefaults, + runtimeBranding, shouldHideDockerUpdateActions, shouldReduceProUpsellNoise, + updateRuntimeBrandingFromResponse, updateSystemSettingsFromResponse, } from '@/stores/systemSettings'; @@ -20,6 +23,7 @@ const repoRoot = path.resolve(frontendRoot, '..'); describe('systemSettings store', () => { beforeEach(() => { markSystemSettingsLoadedWithDefaults(); + clearRuntimeBranding(); }); it('applies route and docker feature flags from API response', () => { @@ -55,6 +59,33 @@ describe('systemSettings store', () => { expect(shouldReduceProUpsellNoise()).toBe(false); }); + it('normalizes the entitlement-filtered runtime branding payload', () => { + updateRuntimeBrandingFromResponse({ + enabled: true, + displayName: ' Acme Operations ', + logoDataUrl: 'data:image/png;base64,YWJj', + }); + + expect(runtimeBranding()).toEqual({ + enabled: true, + displayName: 'Acme Operations', + logoDataUrl: 'data:image/png;base64,YWJj', + }); + + clearRuntimeBranding(); + expect(runtimeBranding()).toEqual({ enabled: false, displayName: '', logoDataUrl: '' }); + }); + + it('drops branding values from a disabled runtime response', () => { + updateRuntimeBrandingFromResponse({ + enabled: false, + displayName: 'Must not render', + logoDataUrl: 'data:image/png;base64,YWJj', + }); + + expect(runtimeBranding()).toEqual({ enabled: false, displayName: '', logoDataUrl: '' }); + }); + it('keeps the telemetry disclosure on the shipped local privacy doc', () => { expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY.md'); }); diff --git a/frontend-modern/src/stores/systemSettings.ts b/frontend-modern/src/stores/systemSettings.ts index e0a49fcc0..7011d3970 100644 --- a/frontend-modern/src/stores/systemSettings.ts +++ b/frontend-modern/src/stores/systemSettings.ts @@ -6,7 +6,7 @@ */ import { createSignal } from 'solid-js'; -import { SettingsAPI } from '@/api/settings'; +import { SettingsAPI, type RuntimeBrandingResponse } from '@/api/settings'; import { logger } from '@/utils/logger'; import type { SystemConfig } from '@/types/config'; @@ -14,6 +14,11 @@ import type { SystemConfig } from '@/types/config'; const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false); // Server-side compatibility setting for proactive commercial prompts const [reduceProUpsellNoise, setReduceProUpsellNoise] = createSignal(false); +const [runtimeBranding, setRuntimeBranding] = createSignal({ + enabled: false, + displayName: '', + logoDataUrl: '', +}); // Track if settings have been loaded const [systemSettingsLoaded, setSystemSettingsLoaded] = createSignal(false); @@ -50,6 +55,29 @@ export async function loadSystemSettings(): Promise { } } +export async function loadRuntimeBranding(): Promise { + try { + updateRuntimeBrandingFromResponse(await SettingsAPI.getRuntimeBranding()); + } catch (err) { + logger.warn('Failed to load runtime branding, using Pulse defaults', err); + clearRuntimeBranding(); + } +} + +export function updateRuntimeBrandingFromResponse(branding: RuntimeBrandingResponse): void { + const enabled = branding.enabled === true; + setRuntimeBranding({ + enabled, + displayName: + enabled && typeof branding.displayName === 'string' ? branding.displayName.trim() : '', + logoDataUrl: enabled && typeof branding.logoDataUrl === 'string' ? branding.logoDataUrl : '', + }); +} + +export function clearRuntimeBranding(): void { + setRuntimeBranding({ enabled: false, displayName: '', logoDataUrl: '' }); +} + /** * Check if Docker update actions (buttons) should be hidden. * Returns true if the server has configured to hide update buttons. @@ -90,3 +118,5 @@ export function updateDockerUpdateActionsSetting(disabled: boolean): void { export function updateReduceProUpsellNoiseSetting(enabled: boolean): void { setReduceProUpsellNoise(enabled); } + +export { runtimeBranding }; diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 5076e3fcc..364b348e2 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -24,6 +24,12 @@ export interface AuthConfig { */ export type UpdateChannel = 'stable' | 'rc'; +export interface ReportBrandSettings { + displayName?: string; + logoBase64?: string; + logoFormat?: 'png' | 'jpg' | 'jpeg' | 'gif' | ''; +} + export interface SystemConfig { pvePollingInterval?: number; // PVE polling interval in seconds pbsPollingInterval?: number; // PBS polling interval in seconds @@ -49,6 +55,7 @@ export interface SystemConfig { disableDockerUpdateActions?: boolean; // Hide Docker update buttons while still detecting updates (server-wide) reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts telemetryEnabled?: boolean; // Outbound usage telemetry, enabled by default unless disabled + reportBranding?: ReportBrandSettings; // Entitlement-gated application and report branding } /** diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 021694ae0..893b514fa 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -55,6 +55,7 @@ import { } from '@/stores/sessionPresentationPolicy'; import { layoutStore } from '@/utils/layout'; import { + loadRuntimeBranding, markSystemSettingsLoadedWithDefaults, updateSystemSettingsFromResponse, } from '@/stores/systemSettings'; @@ -370,7 +371,7 @@ export const useAppRuntimeState = () => { await loadOrganizations(); setWsStore(acquireWsStore()); setBackendHealthy(true); - await loadSystemSettingsAndLayout(); + await Promise.all([loadSystemSettingsAndLayout(), loadRuntimeBranding()]); // Shared commercial posture stays off ordinary self-hosted app shells. if (!presentationPolicyHidesUpgradePrompts()) { void loadCommercialPosture(); diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index df546c42a..c6bcca7e8 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -484,6 +484,7 @@ var allRouteAllowlist = []string{ "/api/vmware/connections/", "/api/admin/profiles/", "/api/config/system", + "/api/runtime/branding", "/api/system/settings/telemetry-preview", "/api/system/settings/telemetry-reset-id", "/api/system/mock-mode", diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index 65d6337c7..b18894426 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -122,6 +122,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { r.mux.HandleFunc("/api/diagnostics", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleDiagnostics))) r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken))) r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig))) + r.mux.HandleFunc("/api/runtime/branding", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.systemSettingsHandler.HandleGetRuntimeBranding))) // Update routes r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates))) r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate))) diff --git a/internal/api/runtime_branding.go b/internal/api/runtime_branding.go new file mode 100644 index 000000000..522271fea --- /dev/null +++ b/internal/api/runtime_branding.go @@ -0,0 +1,115 @@ +package api + +import ( + "encoding/base64" + "net/http" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rs/zerolog/log" +) + +// RuntimeBrandingResponse is the narrow presentation-only branding contract +// available to authenticated monitoring clients. It intentionally omits the +// rest of system settings and returns no configured brand when the active +// runtime lacks the white_label entitlement. +type RuntimeBrandingResponse struct { + Enabled bool `json:"enabled"` + DisplayName string `json:"displayName"` + LogoDataURL string `json:"logoDataUrl"` +} + +func emptyRuntimeBrandingResponse() RuntimeBrandingResponse { + return RuntimeBrandingResponse{} +} + +func runtimeBrandingResponse(settings *config.ReportBrandSettings, entitled bool) RuntimeBrandingResponse { + if !entitled || settings == nil { + return emptyRuntimeBrandingResponse() + } + + displayName := strings.TrimSpace(settings.DisplayName) + logoDataURL := runtimeBrandLogoDataURL(*settings) + if displayName == "" && logoDataURL == "" { + return emptyRuntimeBrandingResponse() + } + + return RuntimeBrandingResponse{ + Enabled: true, + DisplayName: displayName, + LogoDataURL: logoDataURL, + } +} + +func runtimeBrandLogoDataURL(settings config.ReportBrandSettings) string { + decoded, err := config.DecodeReportBrandLogoBase64(settings.LogoBase64) + if err != nil || len(decoded) == 0 { + return "" + } + + detectedFormat := "" + switch http.DetectContentType(decoded) { + case "image/png": + detectedFormat = "png" + case "image/jpeg": + detectedFormat = "jpg" + case "image/gif": + detectedFormat = "gif" + default: + return "" + } + + format, ok := config.CanonicalReportBrandLogoFormat(settings.LogoFormat) + if !ok { + return "" + } + if format == "" { + format = detectedFormat + } + if format != detectedFormat { + return "" + } + + mediaType := "image/" + format + if format == "jpg" { + mediaType = "image/jpeg" + } + return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(decoded) +} + +// HandleGetRuntimeBranding returns only the effective application brand for +// the active licensed runtime. The route itself is monitoring:read so the +// header can render consistently for non-admin viewers. +func (h *SystemSettingsHandler) HandleGetRuntimeBranding(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) + return + } + + service := getLicenseServiceForContext(r.Context()) + entitled := service != nil && service.HasFeature(featureWhiteLabelValue) + if !entitled { + _ = utils.WriteJSONResponse(w, emptyRuntimeBrandingResponse()) + return + } + if h == nil || h.persistence == nil { + _ = utils.WriteJSONResponse(w, emptyRuntimeBrandingResponse()) + return + } + + settings, err := h.persistence.LoadSystemSettings() + if err != nil { + log.Warn().Err(err).Msg("Failed to load runtime branding settings") + _ = utils.WriteJSONResponse(w, emptyRuntimeBrandingResponse()) + return + } + + var configured *config.ReportBrandSettings + if settings != nil { + configured = settings.ReportBranding + } + if err := utils.WriteJSONResponse(w, runtimeBrandingResponse(configured, true)); err != nil { + log.Error().Err(err).Msg("Failed to write runtime branding response") + } +} diff --git a/internal/api/runtime_branding_test.go b/internal/api/runtime_branding_test.go new file mode 100644 index 000000000..e7ab3ccb5 --- /dev/null +++ b/internal/api/runtime_branding_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" +) + +func TestRuntimeBrandingResponseRequiresEntitlement(t *testing.T) { + settings := &config.ReportBrandSettings{ + DisplayName: "Acme Operations", + LogoBase64: base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")), + LogoFormat: "png", + } + + got := runtimeBrandingResponse(settings, false) + if got.Enabled || got.DisplayName != "" || got.LogoDataURL != "" { + t.Fatalf("unentitled branding leaked into runtime response: %+v", got) + } +} + +func TestRuntimeBrandingResponseBuildsCanonicalPresentationPayload(t *testing.T) { + png := []byte("\x89PNG\r\n\x1a\n") + settings := &config.ReportBrandSettings{ + DisplayName: " Acme Operations ", + LogoBase64: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), + } + + got := runtimeBrandingResponse(settings, true) + if !got.Enabled { + t.Fatal("expected configured entitled branding to be enabled") + } + if got.DisplayName != "Acme Operations" { + t.Fatalf("displayName = %q, want trimmed brand", got.DisplayName) + } + if !strings.HasPrefix(got.LogoDataURL, "data:image/png;base64,") { + t.Fatalf("logoDataUrl = %q, want canonical PNG data URL", got.LogoDataURL) + } +} + +func TestRuntimeBrandingResponseKeepsNameWhenLogoIsNotAnImage(t *testing.T) { + settings := &config.ReportBrandSettings{ + DisplayName: "Acme", + LogoBase64: base64.StdEncoding.EncodeToString([]byte("not an image")), + } + + got := runtimeBrandingResponse(settings, true) + if !got.Enabled || got.DisplayName != "Acme" || got.LogoDataURL != "" { + t.Fatalf("unexpected name-only runtime branding: %+v", got) + } +} + +func TestRuntimeBrandingResponseRejectsMismatchedImageFormat(t *testing.T) { + settings := &config.ReportBrandSettings{ + DisplayName: "Acme", + LogoBase64: base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")), + LogoFormat: "gif", + } + + got := runtimeBrandingResponse(settings, true) + if !got.Enabled || got.DisplayName != "Acme" || got.LogoDataURL != "" { + t.Fatalf("mismatched image format must be omitted: %+v", got) + } +} + +func TestHandleGetRuntimeBrandingRejectsUnsupportedMethod(t *testing.T) { + handler := &SystemSettingsHandler{} + request := httptest.NewRequest(http.MethodPost, "/api/runtime/branding", nil) + response := httptest.NewRecorder() + + handler.HandleGetRuntimeBranding(response, request) + + if response.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", response.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleGetRuntimeBrandingReturnsPersistedBrandForEntitledRuntime(t *testing.T) { + service := pkglicensing.NewService() + service.SetCurrentForTesting(&pkglicensing.License{ + Claims: pkglicensing.Claims{ + LicenseID: "lic_runtime_branding", + Email: "brand@example.test", + Tier: pkglicensing.TierEnterprise, + }, + ValidatedAt: time.Now(), + }) + SetLicenseServiceProvider(reportBrandLicenseProvider{service: service}) + t.Cleanup(func() { SetLicenseServiceProvider(nil) }) + + persistence := config.NewConfigPersistence(t.TempDir()) + settings := config.DefaultSystemSettings() + settings.ReportBranding = &config.ReportBrandSettings{ + DisplayName: "Acme Operations", + LogoBase64: base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")), + LogoFormat: "png", + } + if err := persistence.SaveSystemSettings(*settings); err != nil { + t.Fatalf("save system settings: %v", err) + } + + handler := &SystemSettingsHandler{persistence: persistence} + request := httptest.NewRequest(http.MethodGet, "/api/runtime/branding", nil) + response := httptest.NewRecorder() + + handler.HandleGetRuntimeBranding(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", response.Code, http.StatusOK, response.Body.String()) + } + var got RuntimeBrandingResponse + if err := json.NewDecoder(response.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if !got.Enabled || got.DisplayName != "Acme Operations" || + !strings.HasPrefix(got.LogoDataURL, "data:image/png;base64,") { + t.Fatalf("unexpected runtime branding response: %+v", got) + } +} diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 9b2375441..00b5bae74 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -3050,6 +3050,7 @@ func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) { paths := []string{ "/api/config", + "/api/runtime/branding", "/api/storage/host-1", "/api/storage-charts", "/api/charts",