// The only HTTP client of the SPA: openapi-fetch typed by the generated // paths (D-15). Every request carries X-Requested-With (the CSRF header the // admin API requires on state-changing cookie requests, D-19) and same-origin // credentials; the JWT lives in an HttpOnly cookie the SPA never reads. // // A 401 on any call other than login and refresh starts one shared refresh // (concurrent 401s wait for the same one); on success the original request is // replayed once from a clone taken before it was sent. A failed refresh or a // second 401 reports the session as gone. import createClient, { type Middleware } from 'openapi-fetch' import type { paths } from './schema' import { runtime } from '../app/runtime' export const REQUESTED_WITH = 'XMLHttpRequest' type UnauthorizedHandler = () => void type RefreshedHandler = (expiresIn: number | null) => void let unauthorizedHandler: UnauthorizedHandler | null = null let refreshedHandler: RefreshedHandler | null = null let inflight: Promise | null = null const replays = new WeakMap() /** Registers what happens when the session cannot be recovered after a 401. */ export function onUnauthorized(handler: UnauthorizedHandler | null): void { unauthorizedHandler = handler } /** * Reports a session that could not be recovered, for requests that do not * go through the typed client (the upload XMLHttpRequest). */ export function reportUnauthorized(): void { unauthorizedHandler?.() } /** Registers a listener for successful refreshes (the new access lifetime). */ export function onRefreshed(handler: RefreshedHandler | null): void { refreshedHandler = handler } function pathOf(request: Request): string { return new URL(request.url, 'http://local').pathname } function isAuthEndpoint(request: Request): boolean { const path = pathOf(request) return path.endsWith('/auth/login') || path.endsWith('/auth/refresh') } /** * Refreshes the cookie session. Concurrent callers share one request; the * promise resolves to whether the server issued a new session. */ export function refreshSession(): Promise { if (inflight) { return inflight } inflight = (async () => { try { const { data, response } = await api.POST('/auth/refresh') if (!response.ok || !data) { return false } const expiresIn = data.data.expires_in refreshedHandler?.(typeof expiresIn === 'number' ? expiresIn : null) return true } catch { return false } finally { inflight = null } })() return inflight } export const transport: Middleware = { onRequest({ request }) { request.headers.set('X-Requested-With', REQUESTED_WITH) if (!isAuthEndpoint(request)) { replays.set(request, request.clone()) } return request }, async onResponse({ request, response }) { if (response.status !== 401 || isAuthEndpoint(request)) { return response } const replay = replays.get(request) replays.delete(request) if (!replay || !(await refreshSession())) { unauthorizedHandler?.() return response } const retried = await globalThis.fetch(replay) if (retried.status === 401) { unauthorizedHandler?.() } return retried }, } export const api = createClient({ baseUrl: runtime.api, credentials: 'same-origin', // Resolve fetch per call so tests can replace globalThis.fetch. fetch: (request: Request) => globalThis.fetch(request), }) api.use(transport)