Some checks are pending
CI / validate (push) Waiting to run
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>
91 lines
2.7 KiB
TypeScript
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;
|
|
}
|