// Client-side fetch helper with automatic HttpOnly cookie refresh. export interface ApiResponse { data: T; meta: { timestamp: string; version: "v1" }; error: null | { message: string; code?: string }; } const USER_KEY = "ledgerone_user"; export function getStoredToken(): string { return ""; } export function getStoredUser(): T | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(USER_KEY); return raw ? (JSON.parse(raw) as T) : null; } catch { return null; } } export function storeAuthTokens(data: { accessToken?: string; refreshToken?: string; user: unknown; }): void { localStorage.setItem(USER_KEY, JSON.stringify(data.user)); } export function clearAuth(): void { localStorage.removeItem(USER_KEY); } // The backend binds every authenticated request to a single-use, rotating // session nonce: each request must present the current nonce and immediately // invalidates it for the next one. That makes concurrent authenticated // requests fundamentally unsafe — if two calls read the same nonce cookie // before either response comes back to update it (e.g. a dashboard loading // several widgets in parallel with Promise.all), only the first one to reach // the server can win; every other one, including a refresh call caught in // the same race, gets rejected and can cascade into a forced logout. // // The only fully correct fix on this side is to never let more than one // nonce-consuming request be in flight at once: every apiFetch call is // queued and runs strictly after the previous one (refresh-and-retry // included) has completely finished, so the nonce cookie is always settled // before the next request reads it. This only serializes requests within // this browser tab — a second tab open to the same account still shares the // same nonce cookie and could race against this one; that's a separate, // rarer case and not what was happening here. let requestQueue: Promise = Promise.resolve(); function enqueue(task: () => Promise): Promise { const result = requestQueue.then(task, task); requestQueue = result.then( () => undefined, () => undefined ); return result; } async function tryRefresh(): Promise { try { const res = await fetch("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, }); if (!res.ok) { clearAuth(); return false; } const payload = (await res.json()) as ApiResponse; if (payload.error) { clearAuth(); return false; } return true; } catch { clearAuth(); return false; } } async function performFetch( path: string, options: RequestInit ): Promise> { const headers: Record = { ...(options.headers as Record), }; if ( options.body && typeof options.body === "string" && !headers["Content-Type"] ) { headers["Content-Type"] = "application/json"; } let res = await fetch(path, { ...options, headers }); // Auto-refresh on 401 if (res.status === 401) { const refreshed = await tryRefresh(); if (refreshed) { res = await fetch(path, { ...options, headers }); } if (!refreshed || res.status === 401) { if (typeof window !== "undefined") { window.location.href = "/login"; } return { data: null as T, meta: { timestamp: new Date().toISOString(), version: "v1" }, error: { message: "Session expired. Please sign in again." }, }; } } return res.json() as Promise>; } export function apiFetch( path: string, options: RequestInit = {} ): Promise> { return enqueue(() => performFetch(path, options)); }