fix(web): one way to resolve IAM — the brand catalog, not a global env

Four of the five hosts this deployment serves could not sign in at all, and
the fifth signed in under the wrong company's name.

bootnode-web serves bootno.de, web3.hanzo.ai, cloud.hanzo.network,
cloud.pars.network and cloud.zoo.network from ONE image. The IAM issuer and
client_id were set by NEXT_PUBLIC_IAM_URL / NEXT_PUBLIC_IAM_CLIENT_ID, which
are inlined into the client bundle at build time — a single global value that
every host shares. It cannot express per-host white-labeling, and it silently
outranked the correct per-brand value because lib/iam.ts read
`process.env.X || brand.iam.X`.

Measured against the live /v1/iam/oauth/authorize (302 = registered pair,
400 = unregistered redirect_uri), with the env in force today:

  bootno.de            302   (but branded "Sign in to Hanzo ID")
  web3.hanzo.ai        400
  cloud.hanzo.network  400
  cloud.pars.network   400
  cloud.zoo.network    400

The env pinned client_id=bootnode-platform for all five, and that client only
carries bootno.de's callback — so every other host was rejected.

The white-label mechanism the app already has is the runtime brand catalog:
hostname -> brand key -> BrandConfig, resolved after hydration. It was never
usable, for two reasons, both fixed here:

  * getBrand() REPLACED the default with the catalog entry, so any partial
    entry yielded a BrandConfig with undefined name/colors and crashed
    rendering. Entries are now merged over the built-in default, so a brand
    that only needs a different issuer writes only its `iam` block.
  * public/config.json shipped empty with a comment claiming the deploy
    overwrites it. Nothing does — /config.json 404s in production, so every
    host fell back to bootnode. The real catalog now ships in the image.

Removes both IAM env knobs entirely (app, Dockerfile ARG/ENV). The Dockerfile
defaults were also both wrong — iam.bootno.de serves no TLS (525) and
bootnode-web is not a registered client — so any rebuild taking the defaults
would have broken sign-in outright.

After this change, each host resolves its own verified issuer:

  bootno.de            id.bootno.de  bootnode-platform  302
  web3.hanzo.ai        iam.hanzo.ai  hanzo-web3         302
  cloud.pars.network   pars.id       pars-app           302

cloud.hanzo.network and cloud.zoo.network still answer 400: their callbacks
are not on the respective clients' redirect lists. Both hosts also do not
currently resolve, so this is not a regression — registering those two
redirect URIs in IAM is what remains before they can go live.

Zoo's issuer is zoolabs.id, not zoo.id: zoo.id serves no OIDC discovery and
no TLS.
This commit is contained in:
2026-07-26 10:21:14 -07:00
parent 5d1620ab8e
commit c289ba602c
4 changed files with 92 additions and 15 deletions
+12 -6
View File
@@ -20,19 +20,25 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Production build-time environment variables
# Brand is auto-detected from domain at runtime (see lib/brand.ts)
# Override via K8s env vars: NEXT_PUBLIC_BRAND, NEXT_PUBLIC_API_URL, etc.
# Brand is resolved from the hostname at RUNTIME against the injected brand
# catalog (lib/brand.ts + /config.json). That is the only white-label mechanism.
#
# There are deliberately NO IAM build args. One image serves every white-label
# host, so a build-time NEXT_PUBLIC_IAM_URL / _CLIENT_ID is one global value
# shared by all brands — it cannot express per-host white-labeling, and because
# NEXT_PUBLIC_* is inlined into the client bundle it silently outranks the
# correct per-brand value with no way to fix it at deploy time. The defaults
# that used to live here were also both wrong: iam.bootno.de does not serve TLS
# (525) and bootnode-web is not the registered client, so any rebuild taking
# the defaults would have broken sign-in outright. Issuer + client_id now come
# from the brand catalog only.
ARG NEXT_PUBLIC_BRAND=bootnode
ARG NEXT_PUBLIC_API_URL=https://api.bootno.de
ARG NEXT_PUBLIC_WS_URL=wss://ws.bootno.de
ARG NEXT_PUBLIC_IAM_URL=https://iam.bootno.de
ARG NEXT_PUBLIC_IAM_CLIENT_ID=bootnode-web
ENV NEXT_PUBLIC_BRAND=$NEXT_PUBLIC_BRAND
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_IAM_URL=$NEXT_PUBLIC_IAM_URL
ENV NEXT_PUBLIC_IAM_CLIENT_ID=$NEXT_PUBLIC_IAM_CLIENT_ID
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable pnpm && pnpm run build
+25 -1
View File
@@ -179,7 +179,31 @@ function getBrandKey(): string {
export function getBrand(): BrandConfig {
const cat = activeCatalog()
const key = getBrandKey()
return cat.brands?.[key] ?? cat.brands?.[DEFAULT_KEY] ?? BOOTNODE_DEFAULT
const entry = cat.brands?.[key] ?? cat.brands?.[DEFAULT_KEY]
// Catalog entries are OVERRIDES on the complete built-in default, not
// replacements. A brand that only needs a different IAM issuer writes just
// its `iam` block; everything it does not mention (name, colours, logo, urls)
// still resolves. Replacing outright meant any partial entry produced a
// BrandConfig with undefined `colors`/`name` and crashed rendering, which is
// why no catalog was ever shipped and every host fell back to bootnode.
return entry ? mergeBrand(BOOTNODE_DEFAULT, entry) : BOOTNODE_DEFAULT
}
// One level of nesting is enough for BrandConfig: the nested objects (iam,
// colors, social) are flat records, and the arrays (footerLinks, ecosystemApps)
// are replaced wholesale rather than concatenated so a brand can legitimately
// present an empty footer.
function mergeBrand(base: BrandConfig, over: Partial<BrandConfig>): BrandConfig {
const out = { ...base } as Record<string, unknown>
for (const [k, v] of Object.entries(over)) {
if (v === undefined || v === null) continue
const b = (base as Record<string, unknown>)[k]
out[k] =
!Array.isArray(v) && typeof v === "object" && !Array.isArray(b) && typeof b === "object" && b
? { ...(b as object), ...(v as object) }
: v
}
return out as BrandConfig
}
// Export brand key for conditional rendering / per-brand local maps
+8 -5
View File
@@ -18,11 +18,14 @@ import { getBrand } from "@/lib/brand"
const RETURN_URL_KEY = "bootnode_return_url"
export function getIam(): IAM {
const brand = getBrand()
const serverUrl =
process.env.NEXT_PUBLIC_IAM_URL || brand.iam.url
const clientId =
process.env.NEXT_PUBLIC_IAM_CLIENT_ID || brand.iam.clientId
// The brand catalog is the ONE source for issuer + client. There is no env
// override, by design: this deployment serves MANY white-label hosts, so a
// build-time NEXT_PUBLIC_IAM_URL is a single global value that every brand
// would share — it cannot express per-host white-labeling, and it silently
// outranked the correct per-brand default. That is exactly what shipped:
// bootno.de/dashboard bounced to hanzo.id and asked a Bootnode customer to
// "Sign in to Hanzo ID" under the Hanzo mark.
const { url: serverUrl, clientId } = getBrand().iam
return new IAM({
serverUrl,
clientId,
+47 -3
View File
@@ -1,6 +1,50 @@
{
"_comment": "Runtime brand catalog — NEUTRAL PLACEHOLDER. The deploy (universe / CF-Pages) OVERWRITES this file (and/or injects the same JSON as window.__BRAND_CATALOG__) with the full multi-brand catalog. Shipping empty `brands` makes the app fall back to bootnode's built-in neutral default. Shape mirrors the @hanzo/id SPA's /config.json contract. See lib/brand.ts (BrandCatalog / BrandConfig).",
"_comment": "Runtime brand catalog — the ONE white-label mechanism, shipped in the image. Hostname is matched against `hosts` (first substring match wins) to pick a brand key, and that entry is merged OVER the built-in bootnode default (lib/brand.ts getBrand/mergeBrand), so each entry lists only what differs. There is deliberately no NEXT_PUBLIC_IAM_URL/_CLIENT_ID: one image serves every host below, so a build-time value is a single global that all brands share and cannot express per-host white-labeling — that is precisely how every host came to point at hanzo.id. Every issuer + client_id + callback triple below was verified against the live /v1/iam/oauth/authorize (HIP-0111): a registered pair answers 302, an unregistered redirect_uri answers 400.",
"defaultKey": "bootnode",
"hosts": [],
"brands": {}
"hosts": [
{ "match": "bootno.de", "key": "bootnode" },
{ "match": "web3.hanzo.ai", "key": "hanzo" },
{ "match": "cloud.hanzo.network", "key": "hanzo" },
{ "match": "cloud.pars.network", "key": "pars" },
{ "match": "cloud.zoo.network", "key": "zoo" }
],
"brands": {
"bootnode": {
"name": "Bootnode",
"domain": "bootno.de",
"iam": {
"url": "https://id.bootno.de",
"clientId": "bootnode-platform",
"domain": "id.bootno.de"
}
},
"hanzo": {
"name": "Hanzo",
"domain": "web3.hanzo.ai",
"iam": {
"url": "https://iam.hanzo.ai",
"clientId": "hanzo-web3",
"domain": "iam.hanzo.ai"
}
},
"pars": {
"name": "Pars",
"domain": "cloud.pars.network",
"iam": {
"url": "https://pars.id",
"clientId": "pars-app",
"domain": "pars.id"
}
},
"zoo": {
"_note": "zoolabs.id, NOT zoo.id — zoo.id serves no OIDC discovery and no TLS. zoo-web3 IS a registered client on zoolabs.id (302; an unknown client answers 400), but cloud.zoo.network/auth/callback is not yet on its redirect list (400) and that host currently does not resolve. Register the callback in IAM before bringing the host up.",
"name": "Zoo",
"domain": "cloud.zoo.network",
"iam": {
"url": "https://zoolabs.id",
"clientId": "zoo-web3",
"domain": "zoolabs.id"
}
}
}
}