diff --git a/ui/src/lib/tenant-session-recovery.test.ts b/ui/src/lib/tenant-session-recovery.test.ts index 619753a4e7..7384f2cc53 100644 --- a/ui/src/lib/tenant-session-recovery.test.ts +++ b/ui/src/lib/tenant-session-recovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createTenantSessionRecoveryCoordinator, + isArchivedStackRecoveryError, isTenantSessionRecoveryError, } from "./tenant-session-recovery"; @@ -24,6 +25,23 @@ describe("isTenantSessionRecoveryError", () => { }); }); +describe("isArchivedStackRecoveryError", () => { + it("recognizes a 423 archived status-page body", () => { + expect(isArchivedStackRecoveryError(423, { statusPage: { code: "archived" } })).toBe(true); + }); + + it.each([ + [423, { statusPage: { code: "suspended" } }], + [423, { statusPage: { code: "deleted" } }], + [423, { error: "archived" }], + [503, { statusPage: { code: "archived" } }], + [423, { statusPage: null }], + [423, null], + ])("rejects status/body combination %j %j", (status, body) => { + expect(isArchivedStackRecoveryError(status, body)).toBe(false); + }); +}); + describe("tenant-session recovery coordinator", () => { it("reloads once and shares one never-settling promise across concurrent failures", async () => { const reload = vi.fn(); @@ -45,11 +63,22 @@ describe("tenant-session recovery coordinator", () => { expect(settled).toBe(false); }); + it("recovers an archived stack with the same top-level reload", () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + + const recovered = recovery.recoverIfNeeded(423, { statusPage: { code: "archived" } }); + + expect(recovered).not.toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }); + it("does nothing for unrelated responses", () => { const reload = vi.fn(); const recovery = createTenantSessionRecoveryCoordinator(reload); expect(recovery.recoverIfNeeded(401, { error: "unauthorized" })).toBeNull(); + expect(recovery.recoverIfNeeded(423, { statusPage: { code: "suspended" } })).toBeNull(); expect(reload).not.toHaveBeenCalled(); }); }); diff --git a/ui/src/lib/tenant-session-recovery.ts b/ui/src/lib/tenant-session-recovery.ts index 1c7417ee13..210839bfb9 100644 --- a/ui/src/lib/tenant-session-recovery.ts +++ b/ui/src/lib/tenant-session-recovery.ts @@ -9,6 +9,31 @@ export function isTenantSessionRecoveryError(status: number, body: unknown): boo return typeof error === "string" && TENANT_SESSION_ERROR_CODES.has(error); } +/** + * An archived Cloud stack answers every tenant request — including the + * SPA's own health probe — with a 423 archived status page. Left unhandled + * this surfaces as a dead "Failed to load health" screen. The recovery is + * the same top-level reload as an expired session: the Cloud harness + * redirects a fresh browser navigation on an archived host to the + * portfolio, so reloading leaves this document instead of dwelling on it. + */ +export function isArchivedStackRecoveryError(status: number, body: unknown): boolean { + if (status !== 423 || !body || typeof body !== "object") return false; + const statusPage = (body as Record).statusPage; + if (!statusPage || typeof statusPage !== "object") return false; + return (statusPage as Record).code === "archived"; +} + +/** + * Conditions the browser recovers from by re-entering the Cloud harness on a + * fresh top-level navigation: an expired/absent tenant session (the harness + * re-runs the cookie/OIDC handoff) and an archived stack (the harness + * redirects to the portfolio). Both are corrected by the same reload. + */ +export function isTenantDocumentRecoveryError(status: number, body: unknown): boolean { + return isTenantSessionRecoveryError(status, body) || isArchivedStackRecoveryError(status, body); +} + export interface TenantSessionRecoveryCoordinator { recoverIfNeeded: (status: number, body: unknown) => Promise | null; } @@ -20,7 +45,7 @@ export function createTenantSessionRecoveryCoordinator( return { recoverIfNeeded(status, body) { - if (!isTenantSessionRecoveryError(status, body)) return null; + if (!isTenantDocumentRecoveryError(status, body)) return null; if (recoveryPromise) return recoveryPromise; // Keep every affected consumer pending while the browser leaves this @@ -39,8 +64,10 @@ export function createTenantSessionRecoveryCoordinator( } export const tenantSessionRecovery = createTenantSessionRecoveryCoordinator(() => { - // A document navigation re-enters Cloud's existing HttpOnly-cookie/OIDC - // handoff, preserving the current route and query without exposing tokens. + // A document navigation re-enters the Cloud harness, which corrects the + // browser's state: an expired session re-runs the HttpOnly-cookie/OIDC + // handoff (route and query preserved, no tokens exposed), and an archived + // stack is redirected to the portfolio. const topLevelWindow = window.top ?? window; topLevelWindow.location.reload(); });