cards/apps/web/src/lib/api/client.ts
Till JS 87a7a31ece
Some checks are pending
CI / validate (push) Waiting to run
fix(web): SvelteKit-env via \$env/dynamic/public statt import.meta.env
Bug: Browser-Client requested localhost:3081 statt cardecky-api.mana.how
nach Login. Ursache: API_BASE und authBaseUrl() lasen die Variable
über import.meta.env.PUBLIC_*, was unter SvelteKit nicht zuverlässig
inlined wird (Vite-direct, ohne SvelteKit-Wrapper-Hook).

Fix: \$env/dynamic/public liest die env zur Runtime aus den Node-
Server-Variablen (adapter-node) — Browser bekommt sie über den
SSR-Init-Snapshot. Damit muss die Variable nur als runtime-env am
Container hängen, nicht als Build-Arg.

docker-compose.production.yml: PUBLIC_CARDS_API_URL und
PUBLIC_MANA_AUTH_URL aus build.args nach environment verschoben.
Build-Pipeline: cards-web muss neu gebaut werden, sonst greift der
Wechsel von static→dynamic env nicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:35 +02:00

91 lines
2.7 KiB
TypeScript

/**
* Cards-API-Client. Dünner Fetch-Wrapper.
*
* Phase 10c: schickt `Authorization: Bearer <jwt>` 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<Response> {
const headers: Record<string, string> = {
'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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
// 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;
}