Repository Public
1/**2 * Typed client for the same-origin browser API (/api/v1). Authentication is an HttpOnly3 * cookie; the CSRF token lives in memory only (never localStorage/sessionStorage).4 */5import '@/lib/zod-config';6import { ApiError as ApiErrorSchema, AuthStatus } from '@/lib/dto-auth';78export const API_BASE = '/api/v1';910export class ApiRequestError extends Error {11 constructor(12 readonly status: number,13 readonly code: string,14 message: string,15 readonly details?: unknown,16 readonly retryAfterSec?: number,17 ) {18 super(message);19 this.name = 'ApiRequestError';20 }21}2223let csrfToken: string | null = null;24let statusInflight: Promise<AuthStatus> | null = null;2526export function fetchAuthStatus(): Promise<AuthStatus> {27 statusInflight ??= (async () => {28 try {29 const res = await fetch(`${API_BASE}/auth/status`, { credentials: 'same-origin', cache: 'no-store' });30 if (!res.ok) throw await toError(res);31 const status = AuthStatus.parse(await res.json());32 csrfToken = status.csrfToken;33 return status;34 } finally {35 statusInflight = null;36 }37 })();38 return statusInflight;39}4041async function toError(res: Response): Promise<ApiRequestError> {42 let code = `http_${res.status}`;43 let message = res.statusText || `Request failed (${res.status})`;44 let details: unknown;45 try {46 const parsed = ApiErrorSchema.safeParse(await res.json());47 if (parsed.success) ({ code, message, details } = parsed.data.error);48 } catch {49 /* non-JSON error body */50 }51 let retry: number | undefined;52 const d = details as { retryAfterSec?: unknown } | undefined;53 if (d && typeof d.retryAfterSec === 'number') retry = d.retryAfterSec;54 const h = Number(res.headers.get('Retry-After'));55 if (retry === undefined && Number.isFinite(h) && h > 0) retry = h;56 return new ApiRequestError(res.status, code, message, details, retry);57}5859export function isAuthPage(): boolean {60 return typeof location !== 'undefined' && /^\/(login|setup)\/?$/.test(location.pathname);61}62let redirecting = false;63/** Navigates to the login page at most once per page load (parallel 401s must not loop). */64export function redirectToLogin(): void {65 if (typeof location === 'undefined' || isAuthPage() || redirecting) return;66 redirecting = true;67 const next = location.pathname + location.search;68 location.assign(`/login/?next=${encodeURIComponent(next)}`);69}70/** Only same-origin absolute paths are accepted as a post-login destination. */71export function safeNext(raw: string | null): string {72 return raw && raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/login') && !raw.startsWith('/setup') ? raw : '/'; // `/` redirects to Home73}7475interface RequestOpts {76 signal?: AbortSignal;77 /** Do not navigate to /login/ on 401 (auth endpoints handle it themselves). */78 noAuthRedirect?: boolean;79}80const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);8182async function raw(method: string, path: string, body: unknown, opts: RequestOpts, retried = false): Promise<Response> {83 const headers: Record<string, string> = { Accept: 'application/json' };84 if (MUTATING.has(method)) {85 if (csrfToken === null) await fetchAuthStatus().catch(() => undefined);86 if (csrfToken) headers['X-CSRF-Token'] = csrfToken;87 }88 let payload: BodyInit | undefined;89 if (body instanceof Blob || body instanceof ArrayBuffer) {90 payload = body;91 headers['Content-Type'] = 'application/octet-stream';92 } else if (body !== undefined) {93 payload = JSON.stringify(body);94 headers['Content-Type'] = 'application/json';95 }96 const res = await fetch(API_BASE + path, { method, headers, body: payload, credentials: 'same-origin', cache: 'no-store', signal: opts.signal });97 if (res.ok) return res;98 const err = await toError(res);99 if (res.status === 403 && err.code.includes('csrf') && !retried) {100 csrfToken = null;101 await fetchAuthStatus().catch(() => undefined);102 return raw(method, path, body, opts, true);103 }104 if (res.status === 401 && !opts.noAuthRedirect && !path.startsWith('/auth/')) redirectToLogin();105 throw err;106}107108async function json<T>(method: string, path: string, body?: unknown, opts: RequestOpts = {}): Promise<T> {109 const res = await raw(method, path, body, opts);110 if (res.status === 204) return undefined as T;111 const text = await res.text();112 return (text ? JSON.parse(text) : undefined) as T;113}114115export const api = {116 get: <T>(path: string, opts?: RequestOpts) => json<T>('GET', path, undefined, opts),117 post: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('POST', path, body ?? {}, opts),118 put: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PUT', path, body ?? {}, opts),119 patch: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PATCH', path, body ?? {}, opts),120 delete: <T>(path: string, opts?: RequestOpts) => json<T>('DELETE', path, undefined, opts),121 csrf: () => csrfToken,122 clearCsrf: () => {123 csrfToken = null;124 },125};126127/** Some list endpoints may be wrapped (`{items: [...]}`); accept both shapes. */128export function asList<T>(data: unknown): T[] {129 if (Array.isArray(data)) return data as T[];130 if (data && typeof data === 'object') {131 for (const v of Object.values(data)) if (Array.isArray(v)) return v as T[];132 }133 return [];134}135136export function qs(params: Record<string, string | number | boolean | null | undefined>): string {137 const sp = new URLSearchParams();138 for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== '') sp.set(k, String(v));139 const s = sp.toString();140 return s ? `?${s}` : '';141}142143/** WebSocket URL: same origin in production; NEXT_PUBLIC_DEV_WS_ORIGIN only exists in `next dev`. */144export function wsUrl(path: string): string {145 const dev = process.env.NEXT_PUBLIC_DEV_WS_ORIGIN;146 const origin = dev || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;147 return `${origin}${API_BASE}${path}`;148}149150/** Download URL of a content-addressed artifact (served as an attachment, nosniff). */151export const artifactUrl = (sha256: string, name?: string | null): string => `${API_BASE}/artifacts/${encodeURIComponent(sha256)}${qs({ name })}`;152153export function errorMessage(e: unknown): string {154 if (e instanceof ApiRequestError) return e.message;155 if (e instanceof Error) return e.message;156 return 'Unexpected error';157}158