import { configuredBackendUrl } from "@/lib/backend-url" import { getToken, isAuthEnabled, redirectToPortalLogin, redirectToPortalLoginInteractive, } from "@/lib/auth" export class ApiClientError extends Error { constructor( message: string, public readonly status: number, public readonly payload?: unknown, ) { super(message) this.name = "ApiClientError" } } function trimBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/$/, "") } /** Absolute or same-origin-relative URL for backend API paths. */ export function resolveApiUrl(baseUrl: string, path: string): string { if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") { return path } // Safety: never call browser localhost when the UI is served from a remote host if (typeof window !== "undefined") { const host = window.location.hostname const remoteUi = host !== "localhost" && host !== "127.0.0.1" const baseIsLocal = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl)) if (remoteUi && (baseIsLocal || !baseUrl.trim())) { return path.startsWith("/") ? path : `/${path}` } } return trimBaseUrl(baseUrl) + path } /** Attach portal JWT when present. */ export function withAuthHeaders(init?: HeadersInit): Headers { const headers = new Headers(init) const token = typeof window !== "undefined" ? getToken() : null if (token && !headers.has("Authorization")) { headers.set("Authorization", `Bearer ${token}`) } return headers } function handleUnauthorized(): never { if (typeof window !== "undefined" && isAuthEnabled()) { const ok = redirectToPortalLogin() if (!ok) redirectToPortalLoginInteractive() } throw new ApiClientError("Unauthorized", 401) } async function parseErrorMessage(res: Response): Promise { const payload = await res.json().catch(() => undefined) if ( typeof payload === "object" && payload !== null && "error" in payload && typeof (payload as { error?: unknown }).error === "string" ) { return (payload as { error: string }).error } return res.statusText || `HTTP ${res.status}` } export async function requestJson( baseUrl: string, path: string, init?: RequestInit, ): Promise { const hasBody = init?.body != null const headers = withAuthHeaders(init?.headers) if (hasBody && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json") } const res = await fetch(resolveApiUrl(baseUrl, path), { ...init, headers, }) if (res.status === 401) handleUnauthorized() if (res.status === 204) return undefined as T const payload = await res.json().catch(() => undefined) if (!res.ok) { const msg = typeof payload === "object" && payload !== null && "error" in payload && typeof (payload as { error?: unknown }).error === "string" ? (payload as { error: string }).error : res.statusText throw new ApiClientError(msg, res.status, payload) } return payload as T } /** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */ export async function requestBlob( baseUrl: string, path: string, init?: RequestInit, ): Promise { const headers = withAuthHeaders(init?.headers) const res = await fetch(resolveApiUrl(baseUrl, path), { ...init, headers, }) if (res.status === 401) handleUnauthorized() if (!res.ok) { throw new ApiClientError(await parseErrorMessage(res), res.status) } return res }