import { plural } from '#lib/utils/plural.js'; export type ApiError = { code: string; message: string; status: number; /** Seconds until a 429'd request may be retried, from Retry-After. */ retryAfter?: number; }; /** * The sign-in, register and reset screens' wording for a throttled attempt, * or null when err isn't one. Rounds up to whole minutes: the server's * windows are minutes long, and "try again in 1 second" after a lockout * would be untrue the moment it was read. */ export function rateLimitMessage(err: unknown): string | null { const apiErr = err as ApiError | undefined; if (apiErr?.status !== 429) return null; const secs = apiErr.retryAfter; if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.'; const mins = Math.ceil(secs / 60); return `Too many attempts. Try again in ${plural(mins, 'minute', 'minutes')}.`; } export type User = { id: string; username: string; is_admin: boolean; }; export type LoginResponse = { token: string; user: User; }; export async function apiFetch(path: string, init?: RequestInit): Promise { const res = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, ...init }); const body = res.status === 204 ? null : await res.json().catch(() => null); if (!res.ok) { if (res.status === 401) { // Lazy import: auth/store imports from this file, so a top-level // import would be circular. By the time any 401 actually happens // at runtime, both modules have finished loading. const { logout } = await import('#lib/auth/store.svelte.js'); await logout({ silent: true }); } // Two error envelope shapes ship in this codebase today: // 1) {"error": {"code": "...", "message": "..."}} — non-admin endpoints // 2) {"error": "code_string"} — admin endpoints // Without handling (2), every admin error toast renders 'unknown'. const errorField = body && (body as { error?: unknown }).error; let code = 'unknown'; let message = res.statusText; if (typeof errorField === 'string') { code = errorField; } else if (errorField && typeof errorField === 'object') { const env = errorField as { code?: string; message?: string }; code = env.code ?? 'unknown'; message = env.message ?? res.statusText; } const err: ApiError = { code, message, status: res.status }; if (res.status === 429) { const retryAfter = Number(res.headers.get('Retry-After')); if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfter = retryAfter; } throw err; } return body; } export const api = { get: (path: string): Promise => apiFetch(path) as Promise, post: (path: string, body: unknown): Promise => apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, put: (path: string, body: unknown): Promise => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise, patch: (path: string, body: unknown): Promise => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }) as Promise, del: (path: string): Promise => apiFetch(path, { method: 'DELETE' }) as Promise };