// Single typed fetch client for the ERPCore API (docs/20-FRONTEND.md §1). Per-endpoint // methods live in lib/api/*.ts — no scattered fetch() in components. // // Transport: the API is reached SAME-ORIGIN through the Next rewrite in next.config.ts // (/api/* -> BACKEND_ORIGIN). That is why API_BASE is relative and why no CORS setup // exists on the backend: there is no cross-origin request to allow. // // Auth: the session is an httpOnly `erp_at` cookie issued by POST /auth/login (docs/11 // §2.0) — there is no bearer token to read, and by design JS cannot read the cookie. // `credentials: "include"` is what actually authenticates every call. import { ApiResult, ProblemDetails } from "@/types/common" const API_BASE = "/api/v1" /** Fired on window whenever the API answers 403, so the auth context can re-read the person's permissions. */ export const FORBIDDEN_EVENT = "erp:forbidden" /** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */ export class ApiError extends Error { status: number code?: string detail?: string errors?: Record traceId?: string constructor(problem: ProblemDetails) { super(problem.title || "Request failed") this.status = problem.status this.code = problem.code this.detail = problem.detail this.errors = problem.errors this.traceId = problem.traceId } } export interface RequestOptions extends Omit { body?: unknown /** Sent as If-Match for concurrency-guarded PUT/PATCH (docs/11 §1.6). Echo the ETag verbatim, quotes included. */ ifMatch?: string /** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */ idempotencyKey?: string /** Sent as X-XSRF-TOKEN. Now sent automatically on every POST/PUT/PATCH/DELETE (the API checks all of them); kept so a caller can force it. */ csrf?: boolean } /** Reads the non-httpOnly CSRF cookie. It rotates on every session write, so read it per call. */ export function readCsrfToken(): string | null { if (typeof document === "undefined") return null const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/) return match ? decodeURIComponent(match[1]) : null } // erp_at is short-lived; erp_rt (a separate, refresh-route-scoped cookie) outlives it. Before // this, an expired erp_at surfaced as a bare 401 on whatever the user happened to click next // (confirming an invoice, recording a payment, ...) with no attempt to renew the session — this // dedupes concurrent 401s onto one /auth/refresh-token call so every caller gets one retry. let refreshPromise: Promise | null = null function refreshSession(): Promise { if (!refreshPromise) { refreshPromise = fetch(`${API_BASE}/auth/refresh-token`, { method: "POST", credentials: "include", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: "{}", }) .then((res) => res.ok) .catch(() => false) .finally(() => { refreshPromise = null }) } return refreshPromise } function redirectToLogin() { if (typeof window === "undefined") return const next = `${window.location.pathname}${window.location.search}` window.location.href = `/login?next=${encodeURIComponent(next)}` } async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise { const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options // The API double-submit-checks every state-changing request that rides on the session cookie, so send the token // on all of them (it is just echoing a cookie of our own origin). const method = (rest.method ?? "GET").toUpperCase() const stateChanging = method !== "GET" && method !== "HEAD" && method !== "OPTIONS" const csrfToken = csrf || stateChanging ? readCsrfToken() : null // Multipart uploads (attendance files, staff documents) pass a FormData body — the // browser sets its own Content-Type (with boundary), and it must never be JSON-encoded. const isFormData = typeof FormData !== "undefined" && body instanceof FormData const finalHeaders: Record = { Accept: "application/json", ...(body !== undefined && !isFormData ? { "Content-Type": "application/json" } : {}), ...(ifMatch ? { "If-Match": ifMatch } : {}), ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), ...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}), ...((headers as Record | undefined) ?? {}), } const response = await fetch(`${API_BASE}${path}`, { ...rest, credentials: "include", // sends erp_at; the whole auth story depends on this headers: finalHeaders, body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined, }) if (response.status === 401 && !isRetry && !path.startsWith("/auth/")) { const refreshed = await refreshSession() if (refreshed) return rawRequest(path, options, true) redirectToLogin() } // A session older than the CSRF check has no XSRF-TOKEN cookie (or it rotated): renewing the session re-issues it. // Peek at a clone so the normal error path below still reads the body. if (response.status === 403 && !isRetry && !path.startsWith("/auth/")) { const peek = await response.clone().json().catch(() => null) as ProblemDetails | null if (peek?.code === "CSRF_TOKEN_MISMATCH" && (await refreshSession())) return rawRequest(path, options, true) } if (!response.ok) { if (response.status === 403 && typeof window !== "undefined") { // The role may have changed under this tab: let the auth context re-read what the person can still do. window.dispatchEvent(new CustomEvent(FORBIDDEN_EVENT)) } let problem: ProblemDetails try { problem = (await response.json()) as ProblemDetails } catch { // e.g. a proxy/network failure with a non-JSON body. problem = { title: response.statusText || "Request failed", status: response.status } } if (!problem.status) problem.status = response.status throw new ApiError(problem) } return response } /** Fire a request and decode the JSON body only (no ETag needed). */ export async function apiRequest(path: string, options?: RequestOptions): Promise { const response = await rawRequest(path, options) if (response.status === 204) return undefined as T return (await response.json()) as T } /** Fire a request and also surface the ETag header, for resources that support If-Match. */ export async function apiRequestWithETag(path: string, options?: RequestOptions): Promise> { const response = await rawRequest(path, options) const etag = response.headers.get("ETag") const data = response.status === 204 ? (undefined as T) : ((await response.json()) as T) return { data, etag } } /** Build a `?a=1&b=2` query string, dropping null/undefined/empty values. */ export function buildQuery(params: object): string { const search = new URLSearchParams() for (const [key, value] of Object.entries(params) as [string, string | number | boolean | null | undefined][]) { if (value === null || value === undefined || value === "") continue search.set(key, String(value)) } const qs = search.toString() return qs ? `?${qs}` : "" }