mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## 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>
208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
import {
|
|
authSessionSchema,
|
|
currentUserProfileSchema,
|
|
type AuthSession,
|
|
type CurrentUserProfile,
|
|
type UpdateCurrentUserProfile,
|
|
} from "@paperclipai/shared";
|
|
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
|
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
|
|
|
|
type AuthErrorBody =
|
|
| {
|
|
code?: string;
|
|
message?: string;
|
|
error?: string | { code?: string; message?: string };
|
|
}
|
|
| null;
|
|
|
|
export interface SignOutResult {
|
|
success?: boolean;
|
|
redirectTo?: string;
|
|
}
|
|
|
|
export class AuthApiError extends Error {
|
|
status: number;
|
|
code: string | null;
|
|
body: unknown;
|
|
|
|
constructor(message: string, status: number, body: unknown, code: string | null = null) {
|
|
super(message);
|
|
this.name = "AuthApiError";
|
|
this.status = status;
|
|
this.code = code;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
function toSession(value: unknown): AuthSession | null {
|
|
const direct = authSessionSchema.safeParse(value);
|
|
if (direct.success) return direct.data;
|
|
|
|
if (!value || typeof value !== "object") return null;
|
|
const nested = authSessionSchema.safeParse((value as Record<string, unknown>).data);
|
|
return nested.success ? nested.data : null;
|
|
}
|
|
|
|
function extractAuthError(payload: AuthErrorBody, status: number) {
|
|
const nested =
|
|
payload?.error && typeof payload.error === "object"
|
|
? payload.error
|
|
: null;
|
|
const code =
|
|
typeof nested?.code === "string"
|
|
? nested.code
|
|
: typeof payload?.code === "string"
|
|
? payload.code
|
|
: null;
|
|
const message =
|
|
typeof nested?.message === "string" && nested.message.trim().length > 0
|
|
? nested.message
|
|
: typeof payload?.message === "string" && payload.message.trim().length > 0
|
|
? payload.message
|
|
: typeof payload?.error === "string" && payload.error.trim().length > 0
|
|
? payload.error
|
|
: `Request failed: ${status}`;
|
|
|
|
return new AuthApiError(message, status, payload, code);
|
|
}
|
|
|
|
// Rich diagnostics for auth requests. Network-layer failures (Safari
|
|
// "Load failed" / Chrome "Failed to fetch") throw a TypeError *before* any
|
|
// HTTP response, so they are indistinguishable from a bad password in the UI
|
|
// unless we log the resolved request URL + origin here. See PAP-13466.
|
|
function resolveAuthUrl(path: string) {
|
|
const relative = `/api/auth${path}`;
|
|
try {
|
|
return new URL(relative, window.location.origin).href;
|
|
} catch {
|
|
return relative;
|
|
}
|
|
}
|
|
|
|
function logAuthNetworkFailure(method: string, path: string, error: unknown) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("[auth] request failed at the network layer (no HTTP response)", {
|
|
method,
|
|
requestUrl: resolveAuthUrl(path),
|
|
pageOrigin: typeof window !== "undefined" ? window.location.origin : "(no window)",
|
|
pageHref: typeof window !== "undefined" ? redactUrlSecrets(window.location.href) : "(no window)",
|
|
credentials: "include",
|
|
online: typeof navigator !== "undefined" ? navigator.onLine : "(no navigator)",
|
|
errorName: error instanceof Error ? error.name : typeof error,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
error,
|
|
hint:
|
|
"This means the browser never got a response from the server. Common causes: " +
|
|
"the page origin differs from the API host (mixed http/https, wrong hostname/port, " +
|
|
"or a proxy/tunnel that only forwards the page but not /api), an SSL error, or the " +
|
|
"connection was reset. A wrong password would instead return HTTP 401, not this.",
|
|
});
|
|
}
|
|
|
|
function logAuthHttpError(method: string, path: string, status: number, statusText: string, body: unknown) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("[auth] request returned an error status", {
|
|
method,
|
|
requestUrl: resolveAuthUrl(path),
|
|
status,
|
|
statusText,
|
|
body,
|
|
});
|
|
}
|
|
|
|
async function authPost(path: string, body: Record<string, unknown>): Promise<unknown> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`/api/auth${path}`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (networkError) {
|
|
logAuthNetworkFailure("POST", path, networkError);
|
|
throw networkError;
|
|
}
|
|
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);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function authPatch<T>(path: string, body: Record<string, unknown>, parse: (value: unknown) => T): Promise<T> {
|
|
const res = await fetch(`/api/auth${path}`, {
|
|
method: "PATCH",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
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);
|
|
}
|
|
|
|
export const authApi = {
|
|
getSession: async (): Promise<AuthSession | null> => {
|
|
const res = await fetch("/api/auth/get-session", {
|
|
credentials: "include",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
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);
|
|
if (direct) return direct;
|
|
const nested = payload && typeof payload === "object" ? toSession((payload as Record<string, unknown>).data) : null;
|
|
return nested;
|
|
},
|
|
|
|
signInEmail: async (input: { email: string; password: string }) => {
|
|
await authPost("/sign-in/email", input);
|
|
},
|
|
|
|
signUpEmail: async (input: { name: string; email: string; password: string }) => {
|
|
await authPost("/sign-up/email", input);
|
|
},
|
|
|
|
getProfile: async (): Promise<CurrentUserProfile> => {
|
|
const res = await fetch("/api/auth/profile", {
|
|
credentials: "include",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
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);
|
|
},
|
|
|
|
updateProfile: async (input: UpdateCurrentUserProfile): Promise<CurrentUserProfile> =>
|
|
authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)),
|
|
|
|
signOut: async (): Promise<SignOutResult | null> => {
|
|
const payload = await authPost("/sign-out", {});
|
|
if (!payload || typeof payload !== "object") return null;
|
|
|
|
const result = payload as Record<string, unknown>;
|
|
return {
|
|
...(typeof result.success === "boolean" ? { success: result.success } : {}),
|
|
...(typeof result.redirectTo === "string" ? { redirectTo: result.redirectTo } : {}),
|
|
};
|
|
},
|
|
};
|