Files
bootnode/console/lib/api.ts
zeekayandHanzo Dev 3e7d212986 fix(console): one exported apiBase, and no relative /api/ paths
Three dashboard surfaces fetched a RELATIVE /api/v1/... path. That cannot
resolve in a static export — there is no server at that origin — so the
observability panel, the services grid and the AI chat were dead on every
deployed host. It also violates the v1-only rule: the prefix is /v1, never /api.
They now call the host-derived origin at /v1/...

Deleted app/api/health/route.ts, which emitted out/api/health — an /api/ path
SHIPPED INSIDE the artifact.

The deeper fix is the duplication. lib/fleet.ts kept a PRIVATE copy of the
"brand apiUrl, else build-time default" resolver. A second private copy of
exactly that logic, in lib/auth.tsx, shadowed its own import and called itself —
infinite recursion that killed every checkAuth/login/register on any host
classified as local, i.e. all of local development. That copy is already gone;
this removes the other one. apiBase now lives once, exported from lib/api.ts
beside getApiUrl, and fleet.ts imports it.

Left alone deliberately: the /api/v1 strings in app/products/mpc are curl
examples for a CUSTOMER's own host, not calls this app makes.

Console compiles clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 20:01:02 -07:00

326 lines
14 KiB
TypeScript

// 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<T = unknown> =
| { ok: true; data: T }
| { ok: false; why: string }
export async function load<T = unknown>(path: string, init?: RequestInit): Promise<Loaded<T>> {
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<string, string> {
const token = getAuthToken()
const apiKey = getApiKey()
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
export async function api<T = unknown>(path: string, options?: RequestInit): Promise<T> {
const { API_URL } = getApiConfig()
const token = getAuthToken()
const apiKey = getApiKey()
const headers: Record<string, string> = {
"Content-Type": "application/json",
...options?.headers as Record<string, string>,
}
// 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<string, unknown> }) =>
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<string, unknown> }>) =>
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<string, string>)}` : ""
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<TeamMember>("/team", { method: "POST", body: JSON.stringify(data) }),
update: (memberId: string, data: { role?: string; name?: string }) =>
api<TeamMember>(`/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
}