fix(ui): recover expired Cloud tenant sessions (#12826)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Cloud serves each tenant through a browser session and an
HttpOnly cookie.
> - A parked tenant tab can outlive that tenant session.
> - The active SPA then receives a tenant-session 401 from its API calls
and shows the internal error code.
> - A page reload already enters the secure Cloud document and OIDC
handoff and keeps the requested tenant route.
> - This pull request detects only the two Cloud tenant-session 401
codes and starts that existing handoff once.
> - The benefit is that an expired tenant session recovers without
exposing tokens or showing temporary API errors.

## Linked Issues or Issue Description

**What happened?**

A Paperclip Cloud tenant tab can stay open after its HttpOnly tenant
session expires. The next API request returns `401
tenant_session_required` or `401 tenant_session_invalid`. The SPA shows
the internal error code in the full page or in sidebar data consumers. A
manual page refresh clears the error.

**Expected behavior**

The tenant tab must enter the existing Cloud session handoff when an API
request reports an expired tenant session. The handoff must keep the
current route and query. The UI must not show the internal
tenant-session error code.

**Steps to reproduce**

1. Open a Paperclip Cloud tenant route.
2. Keep the SPA open until the tenant session expires.
3. Let the page make an API request.
4. Observe the tenant-session 401 in the page or sidebar.
5. Refresh the page and observe that the existing Cloud handoff restores
the session.

**Paperclip version or commit**

The problem reproduces on `master` at commit `b5f862376`.

**Deployment mode**

Paperclip Cloud tenant deployment.

## What Changed

- Added one tenant-session recovery coordinator for exact top-level
Cloud error codes.
- Reloaded the top-level document once and shared one pending promise
across concurrent failures.
- Applied recovery before normal error handling in the shared API
client, auth API, and health API.
- Applied the same recovery to direct audit CSV exports and provider
trace downloads.
- Preserved ordinary self-hosted 401 behavior and avoided automatic
mutation replay.
- Added tests for exact detection, concurrent failures, auth-session
behavior, health bootstrap, and direct-fetch behavior.

## Verification

- `pnpm exec vitest run --config vitest.config.ts
src/lib/tenant-session-recovery.test.ts src/api/client.test.ts
src/api/auth.test.ts src/api/health.test.ts src/api/heartbeats.test.ts
src/api/audit.test.ts` from `ui/` — 30 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — the changed UI tests passed, but the full local
macOS run also hit existing failures in untouched server worktree and
temporary-path tests.
- GitHub verification — 30 checks passed, no checks failed, and the
Storybook job was intentionally skipped because this PR has no visual
changes.
- Greptile — 5/5 on `fbba29a2f`, with no open findings.

## Risks

- Low risk. Detection requires HTTP 401 and one exact top-level Cloud
error code.
- The recovery promise intentionally stays pending because document
navigation replaces the active SPA.
- If the Paperclip ID session has also expired, the existing Cloud
sign-in flow remains authoritative.
- This change does not modify APIs, cookies, token lifetimes, database
state, or Cloud server code.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex. The agent runtime identifies the model as GPT-5.
The context-window size is not exposed. Reasoning, repository editing,
shell execution, test execution, and GitHub CLI tool use were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-04 07:29:14 -05:00 committed by GitHub
parent b5f8623761
commit 27622c156a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 324 additions and 5 deletions

36
ui/src/api/audit.test.ts Normal file
View File

@ -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);
});
});

View File

@ -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);
}

View File

@ -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" }), {

View File

@ -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<string, unknown>): Promise<un
}
const payload = await res.json().catch(() => 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<T>(path: string, body: Record<string, unknown>, 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);

View File

@ -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<T> {
@ -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<Response>();

View File

@ -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<T>(path: string, init?: RequestInit): Promise<T> {
});
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,

37
ui/src/api/health.test.ts Normal file
View File

@ -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);
});
});

View File

@ -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})`);
}
},

View File

@ -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);
});
});

View File

@ -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}`,
);

View File

@ -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();
});
});

View File

@ -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<string, unknown>).error;
return typeof error === "string" && TENANT_SESSION_ERROR_CODES.has(error);
}
export interface TenantSessionRecoveryCoordinator {
recoverIfNeeded: (status: number, body: unknown) => Promise<never> | null;
}
export function createTenantSessionRecoveryCoordinator(
reloadTopLevelPage: () => void,
): TenantSessionRecoveryCoordinator {
let recoveryPromise: Promise<never> | 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<never>(() => {});
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();
});