/** * Authenticated fetch with refresh-on-401. * * Flow: * 1. Try the request with the current access token. * 2. On 401, attempt ONE refresh (POST /v1/auth/refresh with the * refresh cookie — httpOnly + Secure + SameSite=Lax). If that * succeeds, retry the original request once. * 3. On second 401 (refresh failed or refresh returned 401), clear * local state and force a navigation to /login. * * Threading: all callers await fetchWithAuth; refresh is guarded by * an in-flight Promise so concurrent 401s coalesce into one refresh. */ import { getAccessToken, clearTokens, setTokens, getRefreshFailureUrl } from './auth-state'; let inflightRefresh: Promise | null = null; async function tryRefresh(): Promise { if (inflightRefresh) return inflightRefresh; inflightRefresh = (async () => { try { const r = await fetch('/v1/auth/refresh', { method: 'POST', credentials: 'include', }); if (!r.ok) return false; const data = (await r.json()) as { access_token?: string; refresh_token?: string }; if (!data.access_token || !data.refresh_token) return false; setTokens({ access: data.access_token, refresh: data.refresh_token }); return true; } catch { return false; } finally { inflightRefresh = null; } })(); return inflightRefresh; } export interface FetchOptions extends RequestInit { /** Skip the auth refresh-on-401 retry once. Used for the login * endpoint itself, which is public. */ skipRefresh?: boolean; } export class ApiError extends Error { status: number; body: unknown; constructor(status: number, body: unknown, message: string) { super(message); this.status = status; this.body = body; } } export async function fetchWithAuth( url: string, opts: FetchOptions = {}, ): Promise { const headers = new Headers(opts.headers); const token = getAccessToken(); if (token && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${token}`); } const res = await fetch(url, { ...opts, headers, credentials: 'include' }); if (res.status !== 401 || opts.skipRefresh) { return res; } const ok = await tryRefresh(); if (!ok) { clearTokens(); const target = getRefreshFailureUrl(); if (target && typeof window !== 'undefined') { window.location.href = target; } return res; } const retryHeaders = new Headers(opts.headers); const newToken = getAccessToken(); if (newToken && !retryHeaders.has('Authorization')) { retryHeaders.set('Authorization', `Bearer ${newToken}`); } return fetch(url, { ...opts, headers: retryHeaders, credentials: 'include' }); } export async function apiGet(url: string): Promise { const res = await fetchWithAuth(url); if (!res.ok) { const body = await safeJson(res); throw new ApiError(res.status, body, `${res.status} ${res.statusText}`); } return res.json() as Promise; } export async function apiSend( method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', url: string, body?: unknown, ): Promise { const res = await fetchWithAuth(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const errBody = await safeJson(res); throw new ApiError(res.status, errBody, `${method} ${url} → ${res.status}`); } if (res.status === 204) return undefined as T; return res.json() as Promise; } async function safeJson(res: Response): Promise { try { return await res.json(); } catch { return null; } }