molecule-core/canvas/src/lib/api.ts
rabbitblood 6730c7713d fix(auth): redirect to login on 401 from any API call
When session credentials expire mid-use, ALL API calls return 401.
Previously this threw a generic error that crashed the UI with no
recovery path. Now the API client intercepts 401 and redirects to
login once (via redirectToLogin which already guards against loops).

Combined with the AuthGate /cp/auth/* path guard, this gives the
correct behavior: credentials lost → redirect to login → user logs
in → return_to sends them back.

[Molecule-Platform-Evolvement-Manager]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:16:22 -07:00

62 lines
2.5 KiB
TypeScript

import { getTenantSlug } from "./tenant";
// When NEXT_PUBLIC_PLATFORM_URL is set to "" (empty string), the canvas
// uses relative paths — correct for the combined tenant image where Go
// platform + canvas run on the same port via reverse proxy. The `??`
// operator preserves "" as a valid value; `||` would fall through to
// the localhost default.
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
// 15s is long enough for slow CP queries but short enough that a
// hung backend doesn't leave the UI spinning forever. The abort
// propagates through AbortController so React components can observe
// the error and render a retry affordance.
const DEFAULT_TIMEOUT_MS = 15_000;
async function request<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
// SaaS cross-origin shape:
// - X-Molecule-Org-Slug: derived from window.location.hostname by
// getTenantSlug(). Control plane uses it for fly-replay routing.
// Empty on localhost / non-tenant hosts — safe to omit.
// - credentials:"include": sends the session cookie cross-origin.
// Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
const headers: Record<string, string> = { "Content-Type": "application/json" };
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const res = await fetch(`${PLATFORM_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (res.status === 401) {
// Session expired or credentials lost — redirect to login once.
// Import dynamically to avoid circular dependency with auth.ts.
const { redirectToLogin } = await import("./auth");
redirectToLogin("sign-in");
throw new Error("Session expired — redirecting to login");
}
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
}
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: unknown) => request<T>("POST", path, body),
patch: <T>(path: string, body?: unknown) => request<T>("PATCH", path, body),
put: <T>(path: string, body?: unknown) => request<T>("PUT", path, body),
del: <T>(path: string) => request<T>("DELETE", path),
};