Files
bootnode/console/lib/brand.ts
zeekayandHanzo Dev 88397558f2 refactor(console): the account cluster is the shared chrome, and identity has one table
Two duplications, both removed.

1. The header's account cluster was an email in a <span> and a Sign out button.
   It is now <OrgSwitcher/> + <UserMenu/> from @luxfi/ui — the same controls the
   MPC dashboard and lux.market render, reading the same IAM verbs
   (get-account, get-organizations, get-organization-projects), with the same
   phone sheet, which this bar had no equivalent of at all. Signed out they
   offer sign-in rather than showing an empty list that reads as an account with
   no organizations.

2. lib/brand.ts held a SECOND host→brand table. It kept what is genuinely its
   own — logos, taglines, palettes, footer links, the ecosystem launcher, the
   API origins that answer — and now takes issuer, client id and IAM host from
   @luxfi/ui/white-label, which is the same resolver every Lux surface already
   uses. Two copies of one fact is two things to get wrong, and these two DID
   disagree: this table had Zoo on zoolabs.id (right) and the shared one on
   id.zoo.network (an alias whose own discovery document names zoolabs.id, so a
   client on it rejects every token). The shared table is fixed; this file no
   longer has an opinion to be wrong about.

The bundler wiring for the gui engine comes from `withLuxUi` — the one place it
lives, rather than a hand-rolled transpile list per app.

`next build` passes (static export, every route emitted). `pnpm test` 26/26,
including the assertion that lux.cloud + lux-cloud + https://lux.cloud/auth/callback
— the one login that works today — is unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:59:30 -07:00

301 lines
12 KiB
TypeScript

// White-label by HOSTNAME. One build, one image, every brand.
//
// The brand — identity, theme, API origin, IAM issuer and OIDC client — is a
// pure function of `window.location.hostname`. There is no per-deployment
// config: no NEXT_PUBLIC_BRAND, no injected `window.__BRAND_CATALOG__`, no
// fetched /config.json. Those were the same defect wearing three hats — a
// value chosen at deploy time, while ONE deployment serves every host, so
// whichever brand the last deploy picked was wrong for the other four.
//
// The catalog lives HERE, in source, because it is neither a secret nor an
// environment: it is a fact about which domain belongs to which network, and a
// fact belongs next to the code that reads it.
//
// Every issuer + client_id + callback triple below was verified against the
// live /v1/iam/oauth/authorize (HIP-0111) on 2026-07-26 — a registered pair
// answers 302, an unregistered redirect_uri answers 400:
//
// id.bootno.de bootnode-platform https://bootno.de/auth/callback 302
// hanzo.id hanzo-web3 https://web3.hanzo.ai/auth/callback 302
// lux.id lux-cloud https://lux.cloud/auth/callback 302
// zoolabs.id zoo-cloud https://zoo.cloud/auth/callback 302
// zoolabs.id zoo-cloud https://cloud.zoo.network/auth/callback 302
// pars.id pars-app https://cloud.pars.network/auth/callback 302
// zoolabs.id zoo-web3 https://cloud.zoo.network/auth/callback 400 ← retired
//
// Adding a host = one line in HOSTS. Adding a brand = one entry in BRANDS plus
// registering `https://<host>/auth/callback` on that brand's IAM. Nothing else
// in the app carries a brand string, an issuer, or a client id.
import { resolveWhiteLabel } from "@luxfi/ui/white-label"
export type BrandKey = "bootnode" | "lux" | "zoo" | "hanzo" | "pars"
export type BrandConfig = {
key: BrandKey
name: string // wordmark + document.title, e.g. "Lux Cloud"
shortName: string // compact refs, e.g. "Lux" ("Try Lux")
tagline: string
description: string
logo: string
logoIcon: string
logoWhite: string
favicon: string
// Is the mark a single-colour silhouette? Measured from the assets:
// bootnode-icon.svg is fill="currentColor" and lux-icon.svg is fill="white",
// so as <img> (an isolated document, where currentColor resolves to black)
// one is invisible on black and the other on white. Those two are painted in
// the current text colour via a CSS mask instead — which is also exactly what
// lux.cloud does with its own mark. Zoo's CMYK Venn, Hanzo's badge and Pars'
// gold star carry their own palette and are rendered as images, untouched.
monoMark: boolean
domain: string // canonical public host for this brand
apiUrl: string // bootnode control-plane origin
wsUrl: string
statusUrl: string
networkName: string // e.g. "Lux Network" — header/footer chrome
networkUrl: string
skillsOrg: string // GitHub org for the skills repo
defaultTheme: "light" | "dark"
colors: {
primary: string
primaryForeground: string
accent: string // hero gradient start
accentEnd: string // hero gradient end
}
social: { twitter?: string; github?: string; discord?: string }
iam: {
url: string // OIDC origin the SDK runs discovery against
clientId: string // public PKCE client, always <org>-<app>
domain: string // bare host, for display
idLabel: string // identity brand, e.g. "Lux ID"
}
footerLinks: { name: string; href: string; external?: boolean }[]
// Ecosystem apps — retail-facing services linked from the console launcher.
ecosystemApps: {
name: string
href: string
description: string
icon: string // lucide icon name
category: "explore" | "defi" | "tools" | "governance"
}[]
}
// The bootnode control plane. api.bootno.de, api.web3.hanzo.ai and
// api.cloud.lux.network are the SAME service behind three names — verified
// 2026-07-26, /v1/chains byte-identical across all three
// (sha256 2aa472c11e47d3f67e53be17fb24230fccc94630b485e6f3864d51fa2a4e1400).
// api.cloud.zoo.network and api.cloud.pars.network do NOT resolve, so Zoo and
// Pars point at the one origin that answers rather than at a derived name that
// would fail. Per-brand vanity origins can be added the day they exist.
const API = "https://api.bootno.de"
const WS = "wss://ws.bootno.de"
// Presentation only. The OIDC triple is attached below, from the one shared
// table — see CONSOLE_HOST.
const BRANDS: Record<BrandKey, Omit<BrandConfig, "iam">> = {
// DEFAULT. bootno.de, localhost, and any unmatched host resolve here.
bootnode: {
key: "bootnode",
name: "Bootnode",
shortName: "Bootnode",
tagline: "Blockchain Infrastructure for Developers",
description:
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
logo: "/logo/bootnode-logo.svg",
logoIcon: "/logo/bootnode-icon.svg",
logoWhite: "/logo/bootnode-logo-white.svg",
favicon: "/logo/bootnode-icon.svg",
monoMark: true,
domain: "bootno.de",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.bootno.de",
networkName: "Bootnode",
networkUrl: "https://bootno.de",
skillsOrg: "bootnode",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#a8a8ff", accentEnd: "#8bffd1" },
social: { github: "https://github.com/bootnode" },
footerLinks: [],
ecosystemApps: [],
},
lux: {
key: "lux",
name: "Lux Cloud",
shortName: "Lux",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Lux Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/lux-logo.svg",
logoIcon: "/logo/lux-icon.svg",
logoWhite: "/logo/lux-logo-white.svg",
favicon: "/logo/lux-icon.svg",
monoMark: true,
domain: "lux.cloud",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.lux.network",
networkName: "Lux Network",
networkUrl: "https://lux.network",
skillsOrg: "luxfi",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#a8a8ff", accentEnd: "#8bffd1" },
social: { github: "https://github.com/luxfi" },
footerLinks: [],
ecosystemApps: [],
},
// zoo.cloud + cloud.zoo.network. id.zoo.network is an ALIAS, not the issuer:
// its discovery document advertises issuer/authorize/token all on zoolabs.id
// (measured 2026-07-26), so pointing the SDK at zoolabs.id removes a hop and
// matches the `iss` the token is actually stamped with.
zoo: {
key: "zoo",
name: "Zoo Cloud",
shortName: "Zoo",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Zoo Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/zoo-logo.svg",
logoIcon: "/logo/zoo-icon.svg",
logoWhite: "/logo/zoo-logo-white.svg",
favicon: "/logo/zoo-icon.svg",
monoMark: false,
domain: "zoo.cloud",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.zoo.network",
networkName: "Zoo Network",
networkUrl: "https://zoo.network",
skillsOrg: "zooai",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#8bffd1", accentEnd: "#a8a8ff" },
social: { github: "https://github.com/zooai" },
footerLinks: [],
ecosystemApps: [],
},
hanzo: {
key: "hanzo",
name: "Hanzo",
shortName: "Hanzo",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Hanzo Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/hanzo-logo.svg",
logoIcon: "/logo/hanzo-icon.svg",
logoWhite: "/logo/hanzo-logo-white.svg",
favicon: "/logo/hanzo-icon.svg",
monoMark: false,
domain: "web3.hanzo.ai",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.hanzo.ai",
networkName: "Hanzo Network",
networkUrl: "https://hanzo.ai",
skillsOrg: "hanzoai",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#a8a8ff", accentEnd: "#ff8bc0" },
social: { github: "https://github.com/hanzoai" },
footerLinks: [],
ecosystemApps: [],
},
pars: {
key: "pars",
name: "Pars",
shortName: "Pars",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Pars Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/pars-logo.svg",
logoIcon: "/logo/pars-icon.svg",
logoWhite: "/logo/pars-logo-white.svg",
favicon: "/logo/pars-icon.svg",
monoMark: false,
domain: "cloud.pars.network",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.pars.network",
networkName: "Pars Network",
networkUrl: "https://pars.network",
skillsOrg: "parsdao",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#ffd18b", accentEnd: "#ff8bc0" },
social: { github: "https://github.com/parsdao" },
footerLinks: [],
ecosystemApps: [],
},
}
// Host → brand. Matched EXACTLY or as a dot-suffix, never as a substring: a
// substring match ("lux.cloud" is a substring of "lux.cloud.attacker.example")
// hands an attacker-controlled origin a real brand's IAM client id. First match
// wins, so the more specific host is listed first.
const HOSTS: { host: string; key: BrandKey }[] = [
{ host: "bootno.de", key: "bootnode" },
{ host: "web3.hanzo.ai", key: "hanzo" },
{ host: "cloud.hanzo.network", key: "hanzo" },
{ host: "lux.cloud", key: "lux" },
{ host: "zoo.cloud", key: "zoo" },
{ host: "zoo.network", key: "zoo" }, // covers cloud.zoo.network
{ host: "pars.network", key: "pars" }, // covers cloud.pars.network
]
const DEFAULT_KEY: BrandKey = "bootnode"
// The host each brand's console is REGISTERED under — the one key into the
// shared white-label table.
//
// Issuer, client id and IAM host are NOT this file's to know. They are the same
// answer @luxfi/ui/white-label already gives every Lux surface from the Host
// header, and a second copy here is a second thing to get wrong — which it was:
// this table and that one disagreed about Zoo's issuer, and the surfaces on the
// losing side of that disagreement reject every token they are handed.
//
// This file keeps what is genuinely its own: logos, taglines, palettes, footer
// links, the ecosystem launcher, and the API origins that answer.
const CONSOLE_HOST: Record<BrandKey, string> = {
bootnode: "bootno.de",
lux: "lux.cloud",
zoo: "zoo.cloud",
hanzo: "web3.hanzo.ai",
pars: "cloud.pars.network",
}
/** Presentation + the shared OIDC triple, resolved once per brand. */
const RESOLVED = Object.fromEntries(
(Object.keys(BRANDS) as BrandKey[]).map((key) => {
const wl = resolveWhiteLabel(CONSOLE_HOST[key])
return [
key,
{
...BRANDS[key],
iam: { url: wl.issuer, clientId: wl.clientId, domain: wl.iamDomain, idLabel: `${wl.name} ID` },
},
]
})
) as Record<BrandKey, BrandConfig>
/**
* Resolve the brand key for a hostname. Pass one explicitly to resolve for a
* host other than the current page; omit it in the browser to use
* `window.location.hostname`. During the static export there IS no host, so
* this returns the default and BrandProvider re-resolves after hydration.
*/
export function getBrandKey(hostname?: string): BrandKey {
const host = (hostname ?? (typeof window === "undefined" ? "" : window.location.hostname)).toLowerCase()
if (!host) return DEFAULT_KEY
for (const rule of HOSTS) {
if (host === rule.host || host.endsWith("." + rule.host)) return rule.key
}
return DEFAULT_KEY
}
/** The active brand. Synchronous and total — every key has a complete entry. */
export function getBrand(hostname?: string): BrandConfig {
return RESOLVED[getBrandKey(hostname)]
}