diff --git a/ui/src/api/audit.test.ts b/ui/src/api/audit.test.ts new file mode 100644 index 0000000000..ba1cfab634 --- /dev/null +++ b/ui/src/api/audit.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createTenantSessionRecoveryCoordinator, + tenantSessionRecovery, +} from "@/lib/tenant-session-recovery"; +import { auditApi } from "./audit"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("auditApi.exportAgentActionsCsv", () => { + it("initiates tenant-session recovery for a direct CSV export", async () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + vi.spyOn(tenantSessionRecovery, "recoverIfNeeded").mockImplementation(recovery.recoverIfNeeded); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "tenant_session_invalid" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + )); + + const request = auditApi.exportAgentActionsCsv("company-1"); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + let settled = false; + void request.then( + () => { settled = true; }, + () => { settled = true; }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + }); +}); diff --git a/ui/src/api/audit.ts b/ui/src/api/audit.ts index 2af7eeb60c..3bef8da130 100644 --- a/ui/src/api/audit.ts +++ b/ui/src/api/audit.ts @@ -1,3 +1,4 @@ +import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; import { api } from "./client"; /** @@ -110,6 +111,8 @@ export const auditApi = { ); if (!res.ok) { const body = await res.json().catch(() => null); + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, body); + if (recovery) return recovery; const message = (body as { error?: string } | null)?.error ?? `Export failed: ${res.status}`; throw new Error(message); } diff --git a/ui/src/api/auth.test.ts b/ui/src/api/auth.test.ts index 5f05d928ae..0b2a760baf 100644 --- a/ui/src/api/auth.test.ts +++ b/ui/src/api/auth.test.ts @@ -1,11 +1,54 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createTenantSessionRecoveryCoordinator, + tenantSessionRecovery, +} from "@/lib/tenant-session-recovery"; import { authApi } from "./auth"; -describe("authApi.signOut", () => { - afterEach(() => { - vi.unstubAllGlobals(); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("authApi.getSession", () => { + it("returns null for an ordinary local 401", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(authApi.getSession()).resolves.toBeNull(); }); + it("initiates recovery and stays pending for a Cloud tenant-session 401", async () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + vi.spyOn(tenantSessionRecovery, "recoverIfNeeded").mockImplementation(recovery.recoverIfNeeded); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "tenant_session_required" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const request = authApi.getSession(); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + let settled = false; + void request.then( + () => { settled = true; }, + () => { settled = true; }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + }); +}); + +describe("authApi.signOut", () => { it("returns the managed deployment redirect from the response", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, redirectTo: "/cloud/logout" }), { diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 5bf530d5d7..ebdd4129bd 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -6,6 +6,7 @@ import { type UpdateCurrentUserProfile, } from "@paperclipai/shared"; import { redactUrlSecrets } from "@/lib/redact-url-secrets"; +import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; type AuthErrorBody = | { @@ -125,6 +126,8 @@ async function authPost(path: string, body: Record): Promise null); if (!res.ok) { + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; logAuthHttpError("POST", path, res.status, res.statusText, payload); throw extractAuthError(payload as AuthErrorBody, res.status); } @@ -140,6 +143,8 @@ async function authPatch(path: string, body: Record, parse: }); const payload = await res.json().catch(() => null); if (!res.ok) { + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; throw extractAuthError(payload as AuthErrorBody, res.status); } return parse(payload); @@ -151,9 +156,11 @@ export const authApi = { credentials: "include", headers: { Accept: "application/json" }, }); - if (res.status === 401) return null; const payload = await res.json().catch(() => null); if (!res.ok) { + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; + if (res.status === 401) return null; throw new Error(`Failed to load session (${res.status})`); } const direct = toSession(payload); @@ -177,6 +184,8 @@ export const authApi = { }); const payload = await res.json().catch(() => null); if (!res.ok) { + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load profile (${res.status})`); } return currentUserProfileSchema.parse(payload); diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 9c8efe55ab..0ed8fc231f 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createTenantSessionRecoveryCoordinator, + tenantSessionRecovery, +} from "@/lib/tenant-session-recovery"; import { __inflightGetCount, api, detachInflightGet } from "./client"; interface Deferred { @@ -21,6 +25,10 @@ function jsonResponse(body: unknown) { return { ok: true, status: 200, json: async () => body } as unknown as Response; } +function errorResponse(body: unknown, status = 401) { + return { ok: false, status, json: async () => body } as unknown as Response; +} + const fetchMock = vi.fn(); beforeEach(() => { @@ -29,9 +37,46 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +describe("tenant-session recovery", () => { + it("keeps concurrent failures pending and schedules one top-level reload", async () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + vi.spyOn(tenantSessionRecovery, "recoverIfNeeded").mockImplementation(recovery.recoverIfNeeded); + fetchMock.mockResolvedValue(errorResponse({ error: "tenant_session_required" })); + + const first = api.post("/tenant-session-a", { value: 1 }); + const second = api.post("/tenant-session-b", { value: 2 }); + + await vi.waitFor(() => { + expect(tenantSessionRecovery.recoverIfNeeded).toHaveBeenCalledTimes(2); + expect(reload).toHaveBeenCalledTimes(1); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + let settled = false; + void Promise.all([first, second]).then( + () => { settled = true; }, + () => { settled = true; }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + }); + + it("preserves ApiError handling for an unrelated 401", async () => { + fetchMock.mockResolvedValue(errorResponse({ error: "unauthorized" })); + + await expect(api.get("/ordinary-401")).rejects.toMatchObject({ + name: "ApiError", + status: 401, + message: "unauthorized", + }); + }); +}); + describe("in-tab GET coalescing", () => { it("shares one underlying fetch for identical in-flight GETs", async () => { const d = deferred(); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 923c7f5c15..3966cb0d65 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,4 +1,5 @@ import { getPageVisibility, getVisibilityHeaderValue } from "@/lib/page-visibility"; +import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; const BASE = "/api"; @@ -53,6 +54,8 @@ async function request(path: string, init?: RequestInit): Promise { }); if (!res.ok) { const errorBody = await res.json().catch(() => null); + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, errorBody); + if (recovery) return recovery; throw new ApiError( (errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`, res.status, diff --git a/ui/src/api/health.test.ts b/ui/src/api/health.test.ts new file mode 100644 index 0000000000..0b6a1c4719 --- /dev/null +++ b/ui/src/api/health.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createTenantSessionRecoveryCoordinator, + tenantSessionRecovery, +} from "@/lib/tenant-session-recovery"; +import { healthApi } from "./health"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("healthApi", () => { + it("initiates tenant-session recovery and keeps the bootstrap request pending", async () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + vi.spyOn(tenantSessionRecovery, "recoverIfNeeded").mockImplementation(recovery.recoverIfNeeded); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "tenant_session_invalid" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const request = healthApi.get(); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + let settled = false; + void request.then( + () => { settled = true; }, + () => { settled = true; }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + }); +}); diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index b3ecdbcbc5..8068d2614a 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,4 +1,5 @@ import type { ServerInfoSnapshot } from "@paperclipai/shared"; +import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; export type DevServerHealthStatus = { enabled: true; @@ -51,6 +52,8 @@ export const healthApi = { }); if (!res.ok) { const payload = await res.json().catch(() => null) as { error?: string } | null; + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; throw new Error(payload?.error ?? `Failed to load health (${res.status})`); } return res.json(); @@ -63,6 +66,8 @@ export const healthApi = { }); if (!res.ok) { const payload = await res.json().catch(() => null) as { error?: string } | null; + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; throw new Error(payload?.error ?? `Failed to request restart (${res.status})`); } }, diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts index c466aaf218..8d65eeb9ab 100644 --- a/ui/src/api/heartbeats.test.ts +++ b/ui/src/api/heartbeats.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockApi = vi.hoisted(() => ({ get: vi.fn(), @@ -9,6 +9,15 @@ vi.mock("./client", () => ({ })); import { heartbeatsApi } from "./heartbeats"; +import { + createTenantSessionRecoveryCoordinator, + tenantSessionRecovery, +} from "@/lib/tenant-session-recovery"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("heartbeatsApi.list", () => { beforeEach(() => { @@ -47,3 +56,28 @@ describe("heartbeatsApi.liveRunsForCompany", () => { expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50"); }); }); + +describe("heartbeatsApi.downloadProviderTrace", () => { + it("initiates tenant-session recovery for a direct trace download", async () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + vi.spyOn(tenantSessionRecovery, "recoverIfNeeded").mockImplementation(recovery.recoverIfNeeded); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "tenant_session_required" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + )); + + const request = heartbeatsApi.downloadProviderTrace("run-1"); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + let settled = false; + void request.then( + () => { settled = true; }, + () => { settled = true; }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + }); +}); diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 3351459d4b..777b5ff8cd 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -5,6 +5,7 @@ import type { ProviderTraceFrame, ProviderTraceMetadata, } from "@paperclipai/shared"; +import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; import { api } from "./client"; export interface RunLivenessFields { @@ -168,6 +169,8 @@ export const heartbeatsApi = { const body = (await response.json().catch(() => null)) as { error?: string; } | null; + const recovery = tenantSessionRecovery.recoverIfNeeded(response.status, body); + if (recovery) return recovery; throw new Error( body?.error ?? `Trace download failed: ${response.status}`, ); diff --git a/ui/src/lib/tenant-session-recovery.test.ts b/ui/src/lib/tenant-session-recovery.test.ts new file mode 100644 index 0000000000..619753a4e7 --- /dev/null +++ b/ui/src/lib/tenant-session-recovery.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createTenantSessionRecoveryCoordinator, + isTenantSessionRecoveryError, +} from "./tenant-session-recovery"; + +describe("isTenantSessionRecoveryError", () => { + it.each(["tenant_session_required", "tenant_session_invalid"])( + "recognizes the %s Cloud tenant-session error", + (error) => { + expect(isTenantSessionRecoveryError(401, { error })).toBe(true); + }, + ); + + it.each([ + [403, { error: "tenant_session_required" }], + [401, { error: "unauthorized" }], + [401, { error: "tenant_session_required " }], + [401, { error: { code: "tenant_session_required" } }], + [401, { details: { error: "tenant_session_required" } }], + [401, null], + ])("rejects status/body combination %j %j", (status, body) => { + expect(isTenantSessionRecoveryError(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(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + + const first = recovery.recoverIfNeeded(401, { error: "tenant_session_required" }); + const second = recovery.recoverIfNeeded(401, { error: "tenant_session_invalid" }); + + expect(first).not.toBeNull(); + expect(second).toBe(first); + expect(reload).toHaveBeenCalledTimes(1); + + let settled = false; + void first?.then( + () => { settled = true; }, + () => { settled = true; }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + }); + + it("does nothing for unrelated responses", () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload); + + expect(recovery.recoverIfNeeded(401, { error: "unauthorized" })).toBeNull(); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/tenant-session-recovery.ts b/ui/src/lib/tenant-session-recovery.ts new file mode 100644 index 0000000000..1c7417ee13 --- /dev/null +++ b/ui/src/lib/tenant-session-recovery.ts @@ -0,0 +1,46 @@ +const TENANT_SESSION_ERROR_CODES = new Set([ + "tenant_session_required", + "tenant_session_invalid", +]); + +export function isTenantSessionRecoveryError(status: number, body: unknown): boolean { + if (status !== 401 || !body || typeof body !== "object") return false; + const error = (body as Record).error; + return typeof error === "string" && TENANT_SESSION_ERROR_CODES.has(error); +} + +export interface TenantSessionRecoveryCoordinator { + recoverIfNeeded: (status: number, body: unknown) => Promise | null; +} + +export function createTenantSessionRecoveryCoordinator( + reloadTopLevelPage: () => void, +): TenantSessionRecoveryCoordinator { + let recoveryPromise: Promise | null = null; + + return { + recoverIfNeeded(status, body) { + if (!isTenantSessionRecoveryError(status, body)) return null; + if (recoveryPromise) return recoveryPromise; + + // Keep every affected consumer pending while the browser leaves this + // document. In particular, this avoids surfacing the internal Cloud code + // or causing failed mutations to enter ordinary retry/error handling. + recoveryPromise = new Promise(() => {}); + try { + reloadTopLevelPage(); + } catch (error) { + recoveryPromise = null; + throw error; + } + return recoveryPromise; + }, + }; +} + +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. + const topLevelWindow = window.top ?? window; + topLevelWindow.location.reload(); +});