fix(web): one API resolver, host-derived — delete the localhost fallback

Five different rules decided the API base, and none of them consulted the brand
catalog:

  lib/api.ts        env → window.__BOOTNODE_API_URL__ → localStorage → localhost
  lib/auth.tsx      non-local hostname → getBrand().apiUrl, else env → localhost
  lib/docs-config   env → `https://api.${brand.domain}`
  lib/lux/regions   env → "" (same-origin relative)
  ~18 page files    env → "http://localhost:8000", inline, 24 copies

The literal was one build-arg away from shipping. `next.config.ts` names
Cloudflare Pages as a deploy target and a Pages build has no Dockerfile, so that
path produces a bundle pointing every request at localhost:8000. It was harmless
only because one ARG default in one Dockerfile happened to be set.

`api.${brand.domain}` was already wrong: for the cloud.* white-label hosts it
derives api.cloud.pars.network and api.cloud.zoo.network, neither of which
resolves.

Now there is one resolver, lib/api.ts, and the brand catalog is its authority —
host-derived at runtime, the only thing that can be right when one static image
serves five hosts. `bootnode_api_url` in localStorage stays as the single escape
hatch for pointing a browser at a local backend. The env read, the
window.__BOOTNODE_API_URL__ global and every localhost literal are gone.

Verified on a clean build: tsc 0 errors, build exits 0, and the shipped bundle
contains localhost:8000 in 0 chunks (was 25).

Two traps worth recording, both hit while doing this. Inserting an import after
"the last line starting with `import `" lands INSIDE a multi-line
`import {`. And an import inserted above a `"use client"` directive silently
demotes the file to a Server Component — lib/auth.tsx opens with a comment
banner, so the directive is on line 6, not line 1. Both compiled past tsc and
failed only at the Turbopack build.
This commit is contained in:
2026-07-26 13:07:45 -07:00
parent e09e86f940
commit 4bd5ae988f
21 changed files with 67 additions and 34 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -57,7 +58,7 @@ export default function AnalyticsPage() {
const headers = getAuthHeaders()
// Fetch real data from multiple endpoints
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const [nodesRes, chainsRes, keysRes, webhooksRes] = await Promise.all([
fetch(`${apiUrl}/v1/nodes/`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/chains`, { headers }).catch(() => null),
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -38,7 +39,7 @@ export default function NFTsPage() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nfts/${chain}/owned/${address}`,
`${getApiUrl()}/v1/nfts/${chain}/owned/${address}`,
{ headers: getAuthHeaders() }
)
if (res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -38,7 +39,7 @@ export default function TokensPage() {
setToken(null)
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const res = await fetch(`${apiUrl}/v1/tokens/${chain}/metadata/${contract}`, {
headers: getAuthHeaders()
})
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -44,7 +45,7 @@ export default function ClustersPage() {
return {}
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
async function fetchClusters() {
try {
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import React, { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -96,7 +97,7 @@ interface NetworkConfig {
// Constants & helpers
// ---------------------------------------------------------------------------
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const API_URL = getApiUrl()
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -70,7 +71,7 @@ export default function MonitoringPage() {
useEffect(() => {
async function fetchMonitoring() {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const res = await fetch(`${apiUrl}/v1/monitoring/nodes`, {
headers: getAuthHeaders(),
})
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
@@ -68,7 +69,7 @@ export default function NodeDetailClient() {
async function fetchNode() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/${nodeId}`,
`${getApiUrl()}/v1/nodes/${nodeId}`,
{ headers: getAuthHeaders() }
)
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
@@ -88,7 +89,7 @@ export default function NodeDetailClient() {
}, [nodeId])
async function handleAction(action: "start" | "stop" | "delete") {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
try {
if (action === "delete") {
if (!confirm("Are you sure you want to delete this node?")) return
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -459,7 +460,7 @@ export default function NodesPage() {
async function fetchNodes() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/`, {
const res = await fetch(`${getApiUrl()}/v1/nodes/`, {
headers: getAuthHeaders()
})
if (res.ok) {
@@ -479,7 +480,7 @@ export default function NodesPage() {
setRefreshingMetrics(true)
try {
// Fetch detailed metrics for each node
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const metricsRes = await fetch(`${apiUrl}/v1/nodes/metrics/all`, {
headers: getAuthHeaders()
})
@@ -516,7 +517,7 @@ export default function NodesPage() {
setCreateError(null)
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/`, {
const res = await fetch(`${getApiUrl()}/v1/nodes/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -557,7 +558,7 @@ export default function NodesPage() {
}
async function handleNodeAction(nodeId: string, action: "start" | "stop" | "delete") {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
try {
if (action === "delete") {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}`, {
@@ -585,7 +586,7 @@ export default function NodesPage() {
async function handleDeleteAll() {
if (!confirm(`Are you sure you want to delete ALL ${nodes.length} nodes? This cannot be undone.`)) return
setDeletingAll(true)
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
try {
// Delete all nodes in parallel
await Promise.all(nodes.map(node =>
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -29,7 +30,7 @@ export default function InfrastructurePage() {
useEffect(() => {
async function fetchStats() {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const res = await fetch(`${apiUrl}/v1/infra/stats`, {
headers: getAuthHeaders()
})
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -63,7 +64,7 @@ export default function StoragePage() {
async function fetchData() {
try {
const headers = getAuthHeaders()
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const [volRes, snapRes, cloneRes] = await Promise.all([
fetch(`${apiUrl}/v1/infra/volumes`, { headers }),
fetch(`${apiUrl}/v1/infra/snapshots`, { headers }),
@@ -88,7 +89,7 @@ export default function StoragePage() {
if (!cloneName.trim() || !sourceId) return
setCloning(true)
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const res = await fetch(`${apiUrl}/v1/infra/clone`, {
method: "POST",
headers: {
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -41,7 +42,7 @@ export default function VolumesPage() {
return {}
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
async function fetchVolumes() {
try {
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -77,7 +78,7 @@ export default function MarketsPage() {
async function fetchMarketData() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/staking`,
`${getApiUrl()}/v1/nodes/staking`,
{ headers: getAuthHeaders() }
)
if (res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -35,7 +36,7 @@ export default function OrganizationPage() {
try {
const headers = getAuthHeaders()
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const [teamRes, keysRes] = await Promise.all([
fetch(`${apiUrl}/v1/team`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -66,7 +67,7 @@ export default function DashboardPage() {
const headers = getAuthHeaders()
// Fetch multiple endpoints in parallel
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const apiUrl = getApiUrl()
const [teamRes, keysRes, webhooksRes, chainsRes, nodesRes] = await Promise.all([
fetch(`${apiUrl}/v1/team`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
+1 -1
View File
@@ -39,7 +39,7 @@ export default function SettingsPage() {
setProjectName(localStorage.getItem("bootnode_project_name") || "")
setProjectDescription(localStorage.getItem("bootnode_project_description") || "")
setApiKey(localStorage.getItem("bootnode_api_key") || "")
setApiUrl(process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000")
setApiUrl(getApiUrl())
}, [])
function handleSaveProject() {
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -83,7 +84,7 @@ export default function StakingPage() {
} else if (type === "kms") {
// Check if KMS (Hanzo KSM) is configured
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/keys/status`,
`${getApiUrl()}/v1/keys/status`,
{ headers: getAuthHeaders() }
)
if (res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
@@ -38,7 +39,7 @@ export default function UsagePage() {
<CardContent>
<Badge variant="default">Connected</Badge>
<p className="text-xs text-muted-foreground mt-1">
{process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}
{getApiUrl()}
</p>
</CardContent>
</Card>
+19 -8
View File
@@ -1,17 +1,28 @@
// Bootnode API Client
// Uses auth token from localStorage for authenticated requests
import { getBrand } from "@/lib/brand"
const getApiConfig = () => {
let API_URL = "http://localhost:8000"
// 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') {
API_URL = process.env.NEXT_PUBLIC_API_URL ||
(window as any).__BOOTNODE_API_URL__ ||
localStorage.getItem("bootnode_api_url") ||
API_URL
const override = localStorage.getItem("bootnode_api_url")
if (override) return { API_URL: override.replace(/\/+$/, '') }
}
return { API_URL }
return { API_URL: getBrand().apiUrl.replace(/\/+$/, '') }
}
const getAuthToken = () => {
+2 -1
View File
@@ -3,6 +3,7 @@
// - Production: Hanzo IAM (hanzo.id, zoo.id, lux.id, pars.id)
"use client"
import { getApiUrl } from "@/lib/api"
import { createContext, useContext, useEffect, useState, useCallback } from "react"
import { useRouter } from "next/navigation"
@@ -66,7 +67,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
const isLocal = h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h.startsWith("192.168.") || h.startsWith("10.")
if (!isLocal) return getBrand().apiUrl
}
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
return getApiUrl()
}, [])
useEffect(() => {
+4 -1
View File
@@ -13,7 +13,10 @@ export const docsConfig = {
brandDescription: brand.description,
// API endpoints - detected from env or derived from brand
apiUrl: process.env.NEXT_PUBLIC_API_URL || `https://api.${brand.domain}`,
// brand.apiUrl, not `api.${brand.domain}`: that derivation yields
// api.cloud.pars.network and api.cloud.zoo.network, neither of which
// exists. The catalog carries the real per-brand origin.
apiUrl: brand.apiUrl,
dashboardUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL || `https://${brand.domain}`,
wsUrl: process.env.NEXT_PUBLIC_WS_URL || `wss://ws.${brand.domain}`,
zapUrl: process.env.NEXT_PUBLIC_ZAP_URL || `zap://api.${brand.domain}:9999`,
+3 -1
View File
@@ -1,3 +1,4 @@
import { getApiUrl } from "@/lib/api"
// DigitalOcean Kubernetes regions and Lux network configuration
// Used by the fleet management UI for cluster provisioning and monitoring.
@@ -61,7 +62,8 @@ export const NETWORK_NAMES: LuxNetwork[] = ["mainnet", "testnet", "devnet"] as c
// API Client Helpers
// ---------------------------------------------------------------------------
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
// One resolver for every caller (lib/api.ts), host-derived at runtime.
const API_BASE = getApiUrl();
function authHeaders(): HeadersInit {
const headers: HeadersInit = { "Content-Type": "application/json" };