/** * Cards-API-Client. Dünner Fetch-Wrapper. * * Phase 10c: schickt `Authorization: Bearer ` aus der echten * mana-auth-Session. Wenn kein JWT da ist (Stub-Modus für Tests), * fällt er auf den `X-User-Id`-Header zurück. * * 401 → wir leeren die Session und werfen ApiError. Aufrufer kann * darauf reagieren (z.B. Redirect auf `/`). */ import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { env as publicEnv } from '$env/dynamic/public'; // `$env/dynamic/public` liest die env zur Runtime aus den ENV-Vars // des Node-Servers (adapter-node). Im Browser kommt der Wert aus dem // vom Server gerenderten Init-Snapshot, daher gleicher Pfad für SSR // und Client. Falls nichts gesetzt → localhost:3081 (Dev-Default). export const API_BASE = (() => { const fromPublic = publicEnv.PUBLIC_CARDS_API_URL; if (fromPublic) return fromPublic; if (typeof window !== 'undefined') return 'http://localhost:3081'; return process.env.CARDS_API_URL ?? 'http://localhost:3081'; })(); export class ApiError extends Error { constructor( readonly status: number, readonly body: unknown, message?: string ) { super(message ?? `cards-api ${status}`); } } type RequestOptions = { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; signal?: AbortSignal; }; async function doFetch(path: string, opts: RequestOptions): Promise { const headers: Record = { 'Content-Type': 'application/json', }; if (devUser.token) { headers['Authorization'] = `Bearer ${devUser.token}`; } else if (devUser.stubId) { headers['X-User-Id'] = devUser.stubId; } return fetch(`${API_BASE}${path}`, { method: opts.method ?? 'GET', headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, signal: opts.signal, }); } export async function api(path: string, opts: RequestOptions = {}): Promise { // Proaktive Frische-Prüfung: wenn Token <60s gültig ist, refreshen // wir, bevor der Request rausgeht. Coalesced über tryRefresh(). await devUser.ensureFreshToken(); let res = await doFetch(path, opts); // Reaktive Refresh-Retry: trotz proaktiver Prüfung kann der Token // zwischenzeitlich invalid werden (Server-Rotation, Clock-Skew). // Genau ein Retry nach erfolgreichem Refresh, sonst klassisches 401. if (res.status === 401 && devUser.token) { const refreshed = await devUser.tryRefresh(); if (refreshed) { res = await doFetch(path, opts); } if (res.status === 401) { // Auch nach Refresh 401 → Session ist tot. Lokal leeren, // Landing zeigt Re-Login. devUser.clear(); } } if (!res.ok) { let body: unknown = null; try { body = await res.json(); } catch { body = await res.text(); } throw new ApiError(res.status, body); } return (await res.json()) as T; }