// Bootnode API Client // Uses auth token from localStorage for authenticated requests import { getBrand } from "@/lib/brand" const getApiConfig = () => { // The brand catalog is the authority: it resolves from the HOSTNAME at // runtime, the only thing that can be right when one static image serves // bootno.de, web3.hanzo.ai, cloud.hanzo.network, cloud.pars.network and // cloud.zoo.network. NEXT_PUBLIC_API_URL cannot — it is inlined at build time, // so it is a single global shared by every host. The bootnode-web CR set it to // https://api.web3.hanzo.ai and the shipped bundle used api.bootno.de anyway: // the image is a static export behind the Go hanzoai/static binary, so // container env is never read at all. // // `bootnode_api_url` in localStorage remains the ONE escape hatch, for aiming // a browser at a local backend. The env read, the window.__BOOTNODE_API_URL__ // global, and the "http://localhost:8000" literal are gone — that literal // would have shipped to production from any build without the Dockerfile ARG, // and next.config.ts names Cloudflare Pages (which has no Dockerfile) as a // deploy target. if (typeof window !== 'undefined') { const override = localStorage.getItem("bootnode_api_url") if (override) return { API_URL: override.replace(/\/+$/, '') } } return { API_URL: getBrand().apiUrl.replace(/\/+$/, '') } } const getAuthToken = () => { if (typeof window === 'undefined') return null return localStorage.getItem("bootnode_token") } const getApiKey = () => { if (typeof window === 'undefined') return null return localStorage.getItem("bootnode_api_key") } /** Base API URL (no trailing slash, no /v1) */ // apiBase is THE host-derived API origin: the brand's own apiUrl when the // catalog gives one, else the build-time default. Exported so there is exactly // ONE copy — a second, private copy of this logic is what made lib/auth.tsx // infinitely recursive and killed all local development. export function apiBase(): string { const brandUrl = getBrand().apiUrl if (brandUrl) return brandUrl.replace(/\/$/, "") return getApiUrl() } export function getApiUrl(): string { return getApiConfig().API_URL } /** * Fetch one endpoint and report success or failure HONESTLY. * * The pattern this replaces was `fetch(...).catch(() => null)` followed by * `res?.ok ? await res.json() : []`, repeated across the dashboard. It turned a * 401 after token expiry, a CORS refusal, a 5xx and an unreachable API all into * empty data, and the pages rendered "0 API keys / 0 webhooks / 0 networks" as * though that were measured. A caller cannot distinguish an empty account from * a broken one, which is the definition of a partial function pretending to be * total. * * Callers get a discriminated result: use the data when `ok`, and surface `why` * when not. Nothing is invented on failure. */ export type Loaded = | { ok: true; data: T } | { ok: false; why: string } export async function load(path: string, init?: RequestInit): Promise> { const url = `${getApiUrl()}${path}` try { const r = await fetch(url, { headers: getAuthHeaders(), ...init }) if (!r.ok) return { ok: false, why: `${path} → HTTP ${r.status}` } return { ok: true, data: (await r.json()) as T } } catch (e) { return { ok: false, why: `${path} → ${e instanceof Error ? e.message : String(e)}` } } } /** Join the `why` of every failed result into one sentence, or null if all ok. */ export function loadError(results: Loaded[], total = results.length): string | null { const failed = results.filter((r): r is { ok: false; why: string } => !r.ok) if (!failed.length) return null return `Could not load ${failed.length} of ${total} data sources — the figures shown are incomplete. ${failed .map((f) => f.why) .join("; ")}` } /** Auth headers from token or API key in localStorage */ export function getAuthHeaders(): Record { const token = getAuthToken() const apiKey = getApiKey() if (token) return { "Authorization": `Bearer ${token}` } if (apiKey) return { "X-API-Key": apiKey } return {} } export async function api(path: string, options?: RequestInit): Promise { const { API_URL } = getApiConfig() const token = getAuthToken() const apiKey = getApiKey() const headers: Record = { "Content-Type": "application/json", ...options?.headers as Record, } // Use auth token if available, otherwise fall back to API key if (token) { headers["Authorization"] = `Bearer ${token}` } else if (apiKey) { headers["X-API-Key"] = apiKey } const res = await fetch(`${API_URL}/v1${path}`, { ...options, headers, }) if (!res.ok) { const error = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(error.detail || `API error ${res.status}`) } return res.json() } // Auth / Projects export const auth = { createProject: (data: { name: string; owner_id: string }) => api("/auth/projects", { method: "POST", body: JSON.stringify(data) }), getProject: (id: string) => api(`/auth/projects/${id}`), createKey: (data: { project_id: string; name: string; rate_limit?: number }) => api("/auth/keys", { method: "POST", body: JSON.stringify(data) }), listKeys: (projectId: string) => api(`/auth/keys?project_id=${projectId}`), deleteKey: (keyId: string) => api(`/auth/keys/${keyId}`, { method: "DELETE" }), } // Chains export const chains = { list: () => api("/chains"), get: (chain: string) => api(`/chains/${chain}`), } // RPC export const rpc = { call: (chain: string, network: string, body: { jsonrpc: string; method: string; params: unknown[]; id: number }) => api(`/rpc/${chain}/${network}`, { method: "POST", body: JSON.stringify(body) }), } // Wallets export const wallets = { create: (data: { owner_address: string; chain: string; network?: string; salt?: string }) => api("/wallets/create", { method: "POST", body: JSON.stringify(data) }), get: (address: string) => api(`/wallets/${address}`), list: () => api("/wallets"), estimateGas: (address: string, data: unknown) => api(`/wallets/${address}/estimate`, { method: "POST", body: JSON.stringify(data) }), getNonce: (address: string) => api(`/wallets/${address}/nonce`), } // Webhooks export const webhooks = { create: (data: { name: string; url: string; chain: string; event_type: string; network?: string; filters?: Record }) => api("/webhooks", { method: "POST", body: JSON.stringify(data) }), list: () => api("/webhooks"), get: (id: string) => api(`/webhooks/${id}`), update: (id: string, data: Partial<{ name: string; url: string; is_active: boolean; filters: Record }>) => api(`/webhooks/${id}`, { method: "PATCH", body: JSON.stringify(data) }), delete: (id: string) => api(`/webhooks/${id}`, { method: "DELETE" }), deliveries: (id: string) => api(`/webhooks/${id}/deliveries`), test: (id: string) => api(`/webhooks/${id}/test`, { method: "POST" }), } // Gas export const gas = { prices: (chain: string) => api(`/gas/${chain}/prices`), createPolicy: (data: { name: string; chain: string; network?: string; max_gas_per_op?: number; max_spend_per_day_usd?: number; allowed_contracts?: string[]; allowed_methods?: string[] }) => api("/gas/policies", { method: "POST", body: JSON.stringify(data) }), listPolicies: () => api("/gas/policies"), getPolicy: (id: string) => api(`/gas/policies/${id}`), deletePolicy: (id: string) => api(`/gas/policies/${id}`, { method: "DELETE" }), sponsor: (data: unknown) => api("/gas/sponsor", { method: "POST", body: JSON.stringify(data) }), estimate: (data: unknown) => api("/gas/estimate", { method: "POST", body: JSON.stringify(data) }), } // Tokens export const tokens = { balances: (chain: string, address: string, network?: string) => api(`/tokens/${chain}/balances/${address}${network ? `?network=${network}` : ""}`), metadata: (chain: string, contract: string, network?: string) => api(`/tokens/${chain}/metadata/${contract}${network ? `?network=${network}` : ""}`), } // NFTs export const nfts = { metadata: (chain: string, contract: string, tokenId: string) => api(`/nfts/${chain}/metadata/${contract}/${tokenId}`), collection: (chain: string, contract: string) => api(`/nfts/${chain}/collection/${contract}`), owned: (chain: string, address: string, contracts: string[]) => api(`/nfts/${chain}/owned/${address}?contracts=${contracts.join(",")}`), refresh: (chain: string, contract: string, tokenId: string) => api(`/nfts/${chain}/refresh/${contract}/${tokenId}`, { method: "POST" }), } // Transfers export const transfers = { byAddress: (chain: string, address: string, params?: { from_block?: number; to_block?: number }) => { const qs = params ? `?${new URLSearchParams(params as Record)}` : "" return api(`/transfers/${chain}/address/${address}${qs}`) }, byTx: (chain: string, txHash: string) => api(`/transfers/${chain}/tx/${txHash}`), send: (chain: string, data: { signed_tx: string }) => api(`/transfers/${chain}/send`, { method: "POST", body: JSON.stringify(data) }), } // Billing export const billing = { getUsage: () => api("/billing/usage"), getUsageSummary: (year?: number, month?: number) => { const params = year && month ? `?year=${year}&month=${month}` : "" return api(`/billing/usage/summary${params}`) }, getSubscription: () => api("/billing/subscription"), upgrade: (data: { tier: string; hanzo_subscription_id?: string }) => api("/billing/subscription/upgrade", { method: "POST", body: JSON.stringify(data) }), downgrade: (data: { tier: string }) => api("/billing/subscription/downgrade", { method: "POST", body: JSON.stringify(data) }), getInvoices: (limit?: number) => api(`/billing/invoices${limit ? `?limit=${limit}` : ""}`), getTiers: () => api("/billing/tiers"), getTier: (name: string) => api(`/billing/tiers/${name}`), checkLimits: () => api("/billing/limits"), createCheckout: (data: { tier: string; nonce?: string; source_id?: string; success_url?: string; cancel_url?: string }) => api("/billing/checkout", { method: "POST", body: JSON.stringify(data) }), captureCheckout: (orderId: string) => api(`/billing/checkout/capture/${orderId}`, { method: "POST" }), syncUsage: () => api("/billing/sync", { method: "POST" }), getSyncStatus: () => api("/billing/sync/status"), // Unified IAM + Commerce getAccount: () => api("/billing/account"), getAccountSubscriptions: () => api("/billing/account/subscriptions"), getAccountInvoices: () => api("/billing/account/invoices"), getPaymentMethods: () => api("/billing/account/payment-methods"), syncAccount: () => api("/billing/account/sync", { method: "POST" }), } // Team export const team = { list: () => api<{ members: TeamMember[]; total: number }>("/team"), invite: (data: { email: string; role: string }) => api("/team", { method: "POST", body: JSON.stringify(data) }), update: (memberId: string, data: { role?: string; name?: string }) => api(`/team/${memberId}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (memberId: string) => api(`/team/${memberId}`, { method: "DELETE" }), } // Infrastructure export const infra = { listServices: () => api("/infra/services"), serviceStatus: (service: string) => api(`/infra/services/${service}`), } // Observability export const o11y = { health: () => api("/o11y/health"), metrics: () => api("/o11y/metrics"), serviceMetrics: (service: string) => api(`/o11y/metrics/${service}`), alerts: (severity?: string) => api(`/o11y/alerts${severity ? `?severity=${severity}` : ""}`), createAlertRule: (data: { service: string; metric: string; threshold: number; severity?: string }) => api("/o11y/alerts", { method: "POST", body: JSON.stringify(data) }), serviceMap: () => api("/o11y/service-map"), logs: (params?: { q?: string; service?: string; level?: string; limit?: number }) => { const qs = params ? `?${new URLSearchParams(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))}` : "" return api(`/o11y/logs${qs}`) }, events: (params?: { type?: string; service?: string; limit?: number }) => { const qs = params ? `?${new URLSearchParams(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))}` : "" return api(`/o11y/events${qs}`) }, } // AI Chat export const chat = { completions: (messages: { role: string; content: string }[], stream = true, model?: string) => fetch(`${getApiConfig().API_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", ...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}), ...(getApiKey() ? { "X-API-Key": getApiKey()! } : {}), }, body: JSON.stringify({ messages, stream, model }), }), models: () => api("/chat/models"), } // Fleet Management export const fleets = { list: (chain?: string) => api(`/fleets${chain ? `?chain=${chain}` : ""}`), get: (chain: string, clusterId: string, network: string) => api(`/fleets/${chain}/${clusterId}/${network}`), scale: (chain: string, clusterId: string, network: string, replicas: number) => api(`/fleets/${chain}/${clusterId}/${network}/scale`, { method: "POST", body: JSON.stringify({ replicas }), }), restart: (chain: string, clusterId: string, network: string) => api(`/fleets/${chain}/${clusterId}/${network}/restart`, { method: "POST" }), } // Types export interface TeamMember { id: string email: string name: string | null role: string status: string avatar: string | null joined_at: string | null created_at: string }