diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 03a41e3c1..84595bdcd 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -251,6 +251,13 @@ avoids a cloud-control-plane report data path across clients. describing relay transport mechanics alone. 2. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `agent-lifecycle`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary. 3. `frontend-modern/src/useAppRuntimeState.ts` shared with `performance-and-scalability`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary. + Every recognized authenticated browser session -- local, proxy-auth, or + SSO -- must enter the runtime through the same protected `/api/state` + bootstrap before organization hydration and WebSocket startup. Proxy-auth + and SSO security-status shortcuts may supply visible identity and logout + context, but they must not bypass that canonical server-owned resource + snapshot or leave platform navigation dependent on the first WebSocket + delivery. The app runtime may use `ssoSessionDisplayName` from security status for visible signed-in chrome, but hosted/commercial organization context must remain bound to the stable authenticated principal carried separately in diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a959116f5..f3eddd978 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -158,6 +158,12 @@ admission records a stable refusal without invoking executor or network code. or retained server-owned runtime state. Platform navigation may consume that signal without adding a second state fetch, websocket subscription, route preload, or browser-local cache read on the authenticated hot path. + Local, proxy-auth, and SSO sessions must share exactly one protected + `/api/state` hydration after authentication is established. That single + request must resolve the cold app shell before organization hydration and + WebSocket startup, so a delayed or blocked first stream snapshot cannot + hide server-owned platform scopes; auth-mode branches must not fork + additional state probes or perform the same hydration twice. 17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. Governed action decisions preserve SQLite and MemoryStore parity through one diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 19862f5c7..705a9e0bd 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -466,7 +466,14 @@ describe('App architecture', () => { 'const connectionStatus = createMemo(() => {', ); expect(appRuntimeStateSource).toContain('const showOrgSwitcher = createMemo(() => {'); - expect(appRuntimeStateSource).toContain('const beginAuthenticatedRuntime = async () =>'); + expect(appRuntimeStateSource).toContain( + 'const loadAuthenticatedBootstrapState = async (): Promise => {', + ); + expect(appRuntimeStateSource).toContain( + 'const beginAuthenticatedRuntime = async (): Promise => {', + ); + expect(appRuntimeStateSource).toContain('if (!(await loadAuthenticatedBootstrapState())) {'); + expect(appRuntimeStateSource.match(/apiFetch\('\/api\/state'/g)).toHaveLength(1); expect(appRuntimeStateSource).toContain( 'const [backendHealthy, setBackendHealthy] = createSignal(false);', ); diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 5b9c3639a..45506c025 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -41,6 +41,19 @@ const makeWebSocketState = (overrides: Partial = {}): State => ({ ...overrides, }); +const makeTrueNASResource = (id = 'truenas-1'): Resource => ({ + id, + name: id, + displayName: id, + type: 'agent', + platformId: id, + platformType: 'truenas', + sourceType: 'api', + sources: ['truenas'], + status: 'online', + lastSeen: 1_700_000_000_000, +}); + describe('useAppRuntimeState', () => { let useAppRuntimeState: UseAppRuntimeStateModule['useAppRuntimeState']; let apiFetchMock: ReturnType; @@ -333,8 +346,9 @@ describe('useAppRuntimeState', () => { dispose(); }); - it('uses the SSO display name for app chrome without replacing the stable principal', async () => { + it('uses the SSO display name while bootstrapping TrueNAS state before websocket data', async () => { const principal = 'sso:oidc:test-oidc:stable-principal'; + const bootstrapResource = makeTrueNASResource('truenas-sso'); apiFetchMock.mockImplementation(async (url: string) => { if (url === '/api/security/status') { return new Response( @@ -349,7 +363,13 @@ describe('useAppRuntimeState', () => { ); } if (url === '/api/state') { - return new Response('{}', { status: 200 }); + return new Response( + JSON.stringify({ + resources: [bootstrapResource], + lastUpdate: 1_700_000_000_000, + }), + { status: 200 }, + ); } if (url === '/api/health') { return new Response('{}', { status: 200 }); @@ -364,11 +384,94 @@ describe('useAppRuntimeState', () => { username: 'alice@example.com', logoutURL: '/api/oidc/test-oidc/logout', }); + expect(hookState.state().resources).toEqual([bootstrapResource]); }); expect(hookState.securityStatus()?.ssoSessionUsername).toBe(principal); expect(hookState.hasAuth()).toBe(true); expect(hookState.needsAuth()).toBe(false); + expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false); + expect(hookState.runtimeStateResolved()).toBe(true); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1); + + dispose(); + }); + + it('bootstraps proxy-auth TrueNAS state before websocket data', async () => { + const bootstrapResource = makeTrueNASResource('truenas-proxy'); + apiFetchMock.mockImplementation(async (url: string) => { + if (url === '/api/security/status') { + return new Response( + JSON.stringify({ + hasAuthentication: true, + hasProxyAuth: true, + proxyAuthUsername: 'proxy-operator', + proxyAuthLogoutURL: '/proxy/logout', + }), + { status: 200 }, + ); + } + if (url === '/api/state') { + return new Response( + JSON.stringify({ + resources: [bootstrapResource], + lastUpdate: 1_700_000_000_000, + }), + { status: 200 }, + ); + } + if (url === '/api/health') { + return new Response('{}', { status: 200 }); + } + throw new Error(`Unhandled apiFetch URL: ${url}`); + }); + + const { hookState, dispose } = mountHook(); + + await waitFor(() => { + expect(hookState.state().resources).toEqual([bootstrapResource]); + }); + + expect(hookState.proxyAuthInfo()).toEqual({ + username: 'proxy-operator', + logoutURL: '/proxy/logout', + }); + expect(hookState.needsAuth()).toBe(false); + expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false); + expect(hookState.runtimeStateResolved()).toBe(true); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1); + + dispose(); + }); + + it('does not start the proxy-auth runtime when protected state rejects the session', async () => { + apiFetchMock.mockImplementation(async (url: string) => { + if (url === '/api/security/status') { + return new Response( + JSON.stringify({ + hasAuthentication: true, + hasProxyAuth: true, + proxyAuthUsername: 'proxy-operator', + }), + { status: 200 }, + ); + } + if (url === '/api/state') { + return new Response('{}', { status: 401 }); + } + throw new Error(`Unhandled apiFetch URL: ${url}`); + }); + + const { hookState, dispose } = mountHook(); + + await waitFor(() => { + expect(hookState.isLoading()).toBe(false); + }); + + expect(hookState.needsAuth()).toBe(true); + expect(hookState.enhancedStore()).toBeNull(); + expect(orgsListMock).not.toHaveBeenCalled(); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1); dispose(); }); @@ -412,6 +515,7 @@ describe('useAppRuntimeState', () => { }); expect(hookState.runtimeStateResolved()).toBe(true); expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1); dispose(); }); @@ -447,6 +551,9 @@ describe('useAppRuntimeState', () => { { status: 200 }, ); } + if (url === '/api/state') { + return new Response('{}', { status: 200 }); + } if (url === '/api/health') { return new Response('{}', { status: 200 }); } @@ -463,7 +570,7 @@ describe('useAppRuntimeState', () => { expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false); expect(hookState.runtimeStateResolved()).toBe(true); expect(hookState.state().resources).toEqual([retainedResource]); - expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(false); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/state')).toHaveLength(1); dispose(); }); diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index e1e25c725..021694ae0 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -338,7 +338,34 @@ export const useAppRuntimeState = () => { aiChatStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true); }; - const beginAuthenticatedRuntime = async () => { + const loadAuthenticatedBootstrapState = async (): Promise => { + const stateResponse = await apiFetch('/api/state', { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + }); + + if (stateResponse.status === 401) { + setBootstrapState(null); + setNeedsAuth(true); + return false; + } + + const protectedState = await stateResponse + .clone() + .json() + .then(normalizeBootstrapState) + .catch(() => null); + setBootstrapState(protectedState); + return true; + }; + + const beginAuthenticatedRuntime = async (): Promise => { + if (!(await loadAuthenticatedBootstrapState())) { + return false; + } + setNeedsAuth(false); await loadOrganizations(); setWsStore(acquireWsStore()); @@ -348,6 +375,7 @@ export const useAppRuntimeState = () => { if (!presentationPolicyHidesUpgradePrompts()) { void loadCommercialPosture(); } + return true; }; const checkBackendHealth = async () => { @@ -685,7 +713,10 @@ export const useAppRuntimeState = () => { username: securityData.proxyAuthUsername, logoutURL: securityData.proxyAuthLogoutURL, }); - await beginAuthenticatedRuntime(); + if (!(await beginAuthenticatedRuntime())) { + setIsLoading(false); + return; + } void syncVersionInfoFromUpdateStore(); setIsLoading(false); return; @@ -700,7 +731,10 @@ export const useAppRuntimeState = () => { username: ssoDisplayName, logoutURL: securityData.ssoLogoutURL, }); - await beginAuthenticatedRuntime(); + if (!(await beginAuthenticatedRuntime())) { + setIsLoading(false); + return; + } void syncVersionInfoFromUpdateStore(); setIsLoading(false); return; @@ -725,25 +759,7 @@ export const useAppRuntimeState = () => { return; } - const stateResponse = await apiFetch('/api/state', { - headers: { - 'X-Requested-With': 'XMLHttpRequest', - Accept: 'application/json', - }, - }); - - if (stateResponse.status === 401) { - setBootstrapState(null); - setNeedsAuth(true); - } else { - const protectedState = await stateResponse - .clone() - .json() - .then(normalizeBootstrapState) - .catch(() => null); - setBootstrapState(protectedState); - await beginAuthenticatedRuntime(); - } + await beginAuthenticatedRuntime(); } catch (error) { logger.error('Auth check error', error); try {