Commit Graph
458 Commits
Author SHA1 Message Date
zooqueenandHanzo Dev df50d16787 Render every console page with the brand of the host serving it
Hanzo CI/CD / cicd (push) Failing after 1h10m29s
CI/CD / cicd (push) Failing after 2h37m43s
A static export has no host when it prerenders, so BrandProvider renders the
tree only once the browser has resolved the host, pages that show the brand
are client components, the static head names no brand, and the title and
favicon come from the host. The chain page keeps its static params and
metadata on the server and renders its body on the client, with RPC endpoints
on the brand's API origin at /v1/rpc/{chain}/{network}.

Hanzo's API origin is web3.hanzo.ai itself. The node fleet view, its landing
strip and its nav link belong to the brand that operates a fleet, which is
Lux alone. llms.txt, one file for every host, names no brand and points at
each host's own /docs/api.

Marketing copy states no certification the company does not hold and no
unmeasured uptime, latency, volume or response-time figure. Every internal
link points at a page that exists, every catalog chain is prerendered, and
the routes test holds page bodies to zero dead links.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 12:01:27 -07:00
zooqueenandHanzo Dev ab187aa2be Paint the console ground from the chosen theme, not the OS scheme
The @hanzo/gui engine writes --background onto :root and paints <body> from
it with an unlayered rule for prefers-color-scheme, which outranks every
Tailwind layer, so a visitor on a light OS got a light page under dark text.
The app's ground token is --ground, and an unlayered `html body` rule sets
the ground and ink of the scheme the theme provider chose. The d3 charts
read the same token.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 12:01:09 -07:00
zooqueenandHanzo Dev a479ffa0db Build the web console on our own runners from a lockfile that resolves
hanzo.yml declares the one image, ghcr.io/hanzoai/bootnode:<version>-web,
built from the repository root with console/Dockerfile and named by the
console's own package version, and gates it on the console's unit tests
and typecheck. .hanzo/workflows/cicd.yml runs hanzoai/ci build.yml@v1 on
push to main.

The console manifest names every dependency from the pnpm catalog, and the
catalog carries the versions this code builds against: @luxfi/ui 7.4.3 with
@hanzo/gui 7.3, next 16.2, TypeScript 5.9 and the Zen faces from
@hanzo/font 1.8.0. `pnpm install --frozen-lockfile` and `next build` both
pass. The unused geist and lucide-react entries and the npm lockfile are
gone, tests report in TAP so the pipeline can count them, and the README no
longer calls this repository a retired copy.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 12:01:02 -07:00
zooqueenandHanzo Dev cf106de1da rip(casdoor): name the identity provider Hanzo IAM, and drop the dead manifest
main still called the provider Casdoor in ten places. hanzo.id is served by
github.com/hanzoai/iam, which its own LICENSE states is a clean-room
implementation carrying no third-party licensed source. The Casdoor-derived
server was hanzoai/iam-v1, retired and no part of this stack.

Every fact the text carried is kept. IAM really does stamp iss claims that
openid-client rejects, which is why the NextAuth provider is type: "oauth" and
why setting `issuer` breaks it; the applications really do live in the
iam-init-data configmap. Only the vendor name changed.

iam.py claimed a "Casdoor-compatible" flow. It is a standard flow — RFC 6749 and
OIDC Core — which is what HIP-0111 requires; naming a vendor for a capability
that has an RFC is what the standard exists to stop.

infra/k8s/cloud-deployment.yaml is deleted rather than edited. Its env block
named casdoorEndpoint / casdoorOrganization / casdoorApplication, and nothing
reads them: hanzoai/cloud configures IAM through IAM_URL, IAM_MINT_CLIENT_ID and
CLOUD_IAM_ISSUER, and ships no conf/. Renaming them to IAM_* would have produced
config that looks live and still does nothing. The file is unreferenced by
kustomization.yaml, duplicates declared state that belongs in universe
(infra/k8s/cloud/), pins registry.digitalocean.com instead of ghcr.io/hanzoai,
and pins a floating :latest tag that must never reach a cluster.

Landed directly on main via an isolated worktree. chore/use-datastore-fork is
139/137 divergent from a history rewrite; merging it would re-import that
lineage, and only two commit subjects there are genuinely absent from main.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 13:46:13 -07:00
zeekayandHanzo Dev 96d9a883f8 Take the static base that ships arm64
ghcr.io/hanzoai/static served only linux/amd64 from v0.5.1 through 0.5.14,
so an image built on it has no manifest an arm64 node can pull. 0.5.15 is
the same server source published as an index over both architectures.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 21:22:30 -07:00
zeekayandHanzo Dev 49a1af040e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	console/package.json
#	console/pnpm-lock.yaml
#	console/pnpm-workspace.yaml

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 03:05:54 -07:00
zeekay 6c49cb72ca State a version once
A version written into a manifest is written into every manifest that wants it,
which is how eslint came to exist in a hundred and four forms across this estate
and how a package came to resolve twice at run time. The catalog moves the number
out of the manifest: a manifest names the package, the workspace names the
version.

Four are held below their latest because the newer release cannot be used, and
the reason is recorded rather than the version alone: @noble/hashes 2 renamed
every subpath the web3 stack imports, @splinetool/runtime 2 depends on a package
that was never published, @dynamic-labs 5.4.1 the same, and react-resizable-panels
3 points its types condition at the bundle.
2026-08-29 13:46:56 -07:00
zeekay 8541157772 One version per package, on the current release
Every dependency on a @hanzo/@luxfi/@zooai package names the same version across
the repo: a catalog entry where pnpm supports one, an exact pin where it does
not, and a range only where a peer genuinely means one.

pnpm itself is pinned too — the estate ran eighteen different versions, five of
them too old to read a catalog.
2026-08-28 14:53:51 -07:00
zeekay ed16078f4d feat(type): move the brand and console onto Zen 2026-08-23 13:42:43 -07:00
zeekay 840a43be04 point at the copy that owns the tag
Its 3 unique commits are carried into `hanzoai/bootnode` first — merged onto the default branch or pushed there as `carry/*` refs — so nothing here is lost. This copy has no push mirror, so those commits had reached nothing.
2026-08-21 16:30:39 +00:00
zeekay c647ceace3 stop declaring an image this copy does not own
Its 3 unique commits are carried into `hanzoai/bootnode` first — merged onto the default branch or pushed there as `carry/*` refs — so nothing here is lost. This copy has no push mirror, so those commits had reached nothing.

hanzoai/bootnode keeps ghcr.io/hanzoai/bootnode.
2026-08-21 16:30:38 +00:00
zooqueen 6ce14c0b14 hanzo.yml: drop the deploy: block, which no longer does anything
hanzoai/ci no longer has a deploy step. It cloned a universe repo that 404s,
wrote to a CR directory that universe deleted, and fell back to a kubectl patch
the cluster rejects — so this block has been declaring a rollout that never
happened.

What runs is declared in hanzo/universe, which cd.hanzo.ai reads and applies
within one poll. The build still publishes the image.
2026-08-11 16:28:16 -07:00
zeekayandHanzo Dev 89f1e2bfc1 wip: preserve in-flight work
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:29:33 -07:00
zeekayandHanzo Dev 135a4ede57 chore(fleet): pin lux-fleet to the built Go image
ghcr.io/hanzoai/bootnode:fleet-7016d84, digest
sha256:15340b1b3584d91faed510972768ae6e318e8637afa99f069f36a25074f15703,
built on the in-cluster buildkit pool from Dockerfile.fleet. This is the image
serving api.cloud.lux.network/v1/fleets/lux/overview; the file had been left
pointing at the pre-transport-fix tag.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:46:13 -07:00
zeekayandHanzo Dev bdae39dd94 chore(fleet): drop the cross-cluster reader identity
The collector runs inside lux-k8s now and reads pods with its own in-cluster
ServiceAccount, so the ServiceAccount + ClusterRole + three RoleBindings this
file created for an out-of-cluster observer have no consumer. Applying it again
would only mint another long-lived cluster-read token nobody uses.

Revoked on the cluster too: the lux-k8s RBAC is deleted and the kubeconfig
Secret on bootnode-k8s is gone — the token it held now gets 401.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:44:58 -07:00
zeekayandHanzo Dev 613ca999c0 fix(fleet): serve HTTP, not ZAP
zip.Listen picks its transport from the address scheme, and a bare ":8000"
means ZAP. The dashboard and the kubelet both speak HTTP, so the readiness
probe's "GET " arrived as a ZAP frame header — frame size 1195725856, which is
the ASCII — and the pod never went Ready. The address now carries its scheme.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:36:37 -07:00
zeekayandHanzo Dev dee76a9e38 fix(config): list settings arrive comma-separated, as they are written
Settings() raised SettingsError constructing ALLOWED_ORIGINS. The field is
declared list[str] and documented "comma-separated in env", but
pydantic-settings decodes complex types from the environment as JSON before
any validator runs, so the plain `a,b,c` that every Deployment manifest, .env
and compose file writes was fatal — not a bad value, an unconstructable
Settings.

That made every route depending on it 500: require_admin builds an IAMClient,
which calls get_settings(). The lux.cloud fleet overview returned HTTP 500 the
moment the admin gate shipped, and the failure was in the gate, nowhere near
the collector it guarded.

NoDecode hands the raw string to one validator that splits it, for both list
settings. One variable, one format, decided in one place.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:35:59 -07:00
zeekayandHanzo Dev 734997e46c feat(fleet): the Lux fleet API is Go, and it runs where the fleet is
The lux.cloud dashboard was blank because the collector could not reach a
single validator. Two independent breaks, either one fatal:

  1. It called /ext. luxd 1.36 serves /v1 only — /ext/info is 404 — and the
     collector's _rpc() maps any non-200 to None, so every field came back
     null with no error anywhere.
  2. It dialled 15 hardcoded public LoadBalancer IPs. There are now zero
     LoadBalancer Services in lux-mainnet/testnet/devnet; those IPs do not
     answer at all (connect timeout, not 404).

Both are the same root defect: the observer was outside the thing it observes
and had to be told where to look. So the observer moves in. Running in lux-k8s,
a validator's address is its pod IP, read from the pod list on every
collection — there is nothing left to configure and nothing left to go stale.
Chain facts come from the node itself over /v1: info.getNodeVersion,
getNodeID, getNodeIP, peers, isBootstrapped per chain, eth_blockNumber at
/v1/bc/C/rpc (the bc/ segment is load-bearing; /v1/C/rpc is not a route) and
platform.getHeight at /v1/bc/P, which quotes its integer.

That also collapses the MODE A / MODE B split: there is one path, not two.

Two corrections to what the old collector reported:

  version is now what the fleet RUNS, not what the CR declares. The mainnet
  LuxNetwork CR asks for ghcr.io/luxfi/node:v1.34.0, a tag that does not
  exist; the nodes are on luxd/1.36.2. The dashboard must show the running
  truth, so observation wins and image keeps the declared value.

  external_ip becomes ip, matching console/lib/fleet.ts, and carries the
  address the node advertises for itself (info.getNodeIP) rather than an
  address someone wrote down.

Degraded is reported as degraded. A node that does not answer keeps every
field null and counts against nodes_rpc_ok; it is never smoothed over.

Placement: this stays in hanzoai/bootnode rather than folding into
hanzoai/cloud. cloud is not deployed on lux-k8s, and standing the whole
monolith up inside the chain cluster to run a read-only collector is a far
larger change than the collector itself. The surface is registered with zip's
Group; the route set is unchanged.

Deploy: Deployment/Service/RBAC in lux-k8s (lux-fleet.yaml, read-only —
list/get on pods and luxnetworks, no pods/proxy, no exec). The public URL
resolves to bootnode-k8s, so that cluster forwards just /v1/fleets/lux across
(lux-fleet-bridge.yaml); no other path on the host is touched.

Tests: a luxd stub that 404s anything outside /v1 and quotes platform height;
assertions that every probe path is under /v1, that no wire key is ever
omitted (an absent key reads as undefined and blanks a cell), that a down node
stays null, and that /v1/fleets/lux/zzzz is 404 next to a 200 on /overview.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:31:06 -07:00
zeekayandHanzo Dev 55aadd328c chore(lux): pin lux-fleet to the image that carries the /v1 collector
The floating :api tag made the running code unknowable from the manifest.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:28:38 -07:00
zeekayandHanzo Dev 3f54647e51 fix(lux): the reported version is the one that is running
lux-operator is scaled to 0, so the LuxNetwork CR's image tag is only a
declaration and can be arbitrarily stale. Mainnet's CR says v1.34.0 — a tag
that does not exist in the registry — while all five nodes answer
info.getNodeVersion with luxd/1.36.2; testnet's says v1.32.12 against a
running 1.36.24.

Reporting the CR tag as `version` would put a fabricated number on the
dashboard. `version` is now the observed value and `image` stays as declared,
so a drift between intent and reality is visible instead of hidden.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:26:09 -07:00
zeekayandHanzo Dev 1f30a4c21c fix(lux): the fleet is observed through lux-k8s, on /v1
luxd serves /v1 only — server/http/server.go pins baseURL="/v1" — so every
/ext call in this repo was a silent no-op. The fleet collector's _rpc()
returns None on non-200, so the lux.cloud dashboard rendered nodes_rpc_ok:0,
version:null and every node rpc_reachable:false while the fleet was in fact
running luxd/1.36.2 (mainnet), 1.36.24 (testnet), 1.36.25 (devnet).

Fixing the path alone would not have helped: the collector addressed each
validator by a hardcoded public LoadBalancer IP, and there are now zero
LoadBalancer Services in lux-mainnet/testnet/devnet. Those IPs time out. So
node addressing is no longer configured — it is derived from the cluster on
every collection, and the one way to reach a validator is the lux-k8s API
server's pod proxy, since luxd RPC is cluster-internal.

That collapses the old MODE A / MODE B split into a single path: read the CR
for the declared shape, the pods for phase/readiness/IP, then each pod's
JSON-RPC through the proxy. One credential authenticates both. Deleting the
endpoint ConfigMap and its 15 baked-in IPs removes the failure mode where
infrastructure moves and a dashboard quietly reports zeros.

Probes move to /v1/health/liveness, not /v1/health: a degraded-but-running
validator answers /v1/health with 503, so probing it would restart nodes that
are merely behind. Any fleet from charts/lux could never have gone Ready.

LuxNode.external_ip becomes .ip — with no public per-pod endpoint, the honest
value is the pod IP.

Avalanche endpoints keep /ext; that is their API, not ours.

Measured, with controls: pod-proxy POST /v1/info -> 200 on all 15 pods;
/ext/health -> 404; /zzz/health -> 404; nonexistent pod -> 404. A test asserts
every probe path is under /v1 and fails if /ext returns.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:18:04 -07:00
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
zeekayandHanzo Dev da98c0de75 feat(console): one icon set — every glyph comes from @luxfi/ui/icons
74 files imported `lucide-react` directly, pinned ^1.26.0, while @luxfi/ui —
which this console renders alongside — resolves 1.27. Two Lucide copies from two
resolutions in one bundle is how a set stops being one set. Two more glyphs were
not from any icon library at all: the node list drew its network in/out arrows as
the literal characters "↓" and "↑", and the chain onboarding selector drew its
tick as "✓". Those are font glyphs — they take the text metrics, not the icon
box, so they sit on a different baseline and scale with the font, not `size-*`.

One source now: `@luxfi/ui/icons`, Lucide as @hanzo/gui standardises on it and
@luxfi/ui themes it. `lucide-react` is out of package.json, so per-surface drift
is no longer expressible; @luxfi/ui comes in at 7.4.3, which is where `./icons`
lives. The three text glyphs are ArrowDown / ArrowUp / Check.

All 121 distinct icon names this console uses were checked against the 6014
symbols @luxfi/ui/icons exports before the rewrite — zero missing, with a
negative control. `next build` is clean: 78 routes, Next 16.2.11 + Turbopack.

The d3 chart roots (node-globe, node-map, globe-view, donut-chart, fleet-map)
keep their <svg>: those are plots, not glyphs. The command menu's "↑↓" stays —
it is a keyboard legend inside <kbd>, and it means the keys.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:57:55 -07:00
zeekayandHanzo Dev 2e11985646 monitoring: probe IAM at /healthz; document the OIDC userinfo path
/v1/iam is guarded — a dashboard probe there shows IAM as down when it is up.
/healthz answers 200 unauthenticated. Userinfo is /v1/iam/oauth/userinfo in
the deployed route table; there is no /v1/iam/userinfo.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 11:43:22 -07:00
zeekayandHanzo Dev af41c70a23 iam: drop /api/ path prefix, use /v1/iam/
Hanzo standard (hanzoai/openapi): every route is /v1/<service>/<resource>.
The /api/ dialect is dead on the deployed IAM (iam v1.33.24 serves 154
paths, all under /v1/iam). Prefix only; route spelling unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 11:38:55 -07:00
zeekay 67150ebda2 ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:25:58 -07:00
zeekay 7a8ac7d599 ci: nudge git.hanzo.ai to pull on push
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.

Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
2026-07-27 08:09:44 -07:00
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
zeekayandHanzo Dev 7d293212cb refactor: web/ is now console/ — bootnode/bootnode is the backend, bootnode/console the frontend
Names the two halves for what they are. The repo already holds both (12407e9 merged
bootnode/bootnode into this history); this makes the frontend directory say so, so the layout
reads the way the owner describes the system: backend and frontend separate, one repo.

git mv throughout — 187 renames, history preserved. Every path reference follows: hanzo.yml
build context and dockerfile, compose.yml, compose.prod.yml, start.sh, LLM.md,
infra/k8s/web-deployments.yaml, infra/cloud/deploy.sh, and the Dockerfile's own COPY paths.
Image and container names (bootnode-web, the -web tag suffix) are deliberately NOT renamed here:
those are a deploy contract the App CR and ingress bind to on port 3001, and breaking them is a
separate, staged change.

Console builds green after the move; the Go tree builds.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 17:47:51 -07:00
zeekay 8f353bf348 one console shell, one image, brand from the Host header
The shell and design system half of the console merge. lux.cloud's look and
feel comes across as tokens and shell geometry, so all 67 existing routes
inherit it without one page being edited, and the per-deployment brand is
replaced by a hostname resolve.

DESIGN SYSTEM — lux.cloud's palette becomes the token layer
  app/globals.css now states lux.cloud's scheme in the variables Tailwind
  already reads: #000 ground, #f5f5f5 ink, 55% muted, 8% hairline / 18% strong
  border, #1a1a1a active fill, its status greens/ambers/reds, 10px/16px radii,
  clamp() display type, -0.01em tracking. Light is a faithful inversion, kept
  because the app ships a theme toggle. Because these are the same variables
  `bg-background` / `border-border` / `text-muted-foreground` always resolved
  to, every page changed appearance without changing.

  The shared primitives then say it in their own geometry: Card is lux's
  `.panel` (16px, hairline, no shadow — a shadow is invisible on black and
  grime on white), Table is `.vtable` (12px uppercase heads, border on the TOP
  of each row so the header needs no rule and the table ends flush), Badge is
  `.pill` with the status dot, Button and Input are `.btn` and `.stake-input`.

SHELL
  One header for marketing and console, as on lux.cloud — 57px, blurred, one
  hairline, brand lockup with the retracting wordmark, actions hard right,
  collapsing behind a single hamburger below `lg` into an OPAQUE sheet (a
  nested backdrop-filter re-roots the backdrop, the bug lux.cloud documents).
  ConsoleLayout adds the one thing the console needs: a 236px sticky rail that
  becomes a horizontally-scrolling strip on a phone — 7% of a 390x844 screen
  measured, against the 27% a wrapped grid of pills costs.

  Deleted: unified-navigation.tsx, a second console-only top bar with its own
  logo, an org switcher whose options changed nothing, and a bell wired to no
  notification source. The hard-coded `pl-64 pt-16` offsets are now the
  --header-h / --sidebar-w tokens, so the header and rail cannot disagree.

  Loading, empty and error are three components in one file instead of nine
  hand-rolled spinners at four sizes, and the callback page's `bg-green-100` /
  `text-red-600` — the only two colours in the app that ignored the tokens and
  so stayed light-mode on a black page — are gone.

WHITE-LABEL BY HOSTNAME — one image, five hosts
  lib/brand.ts resolves brand, theme, API origin, IAM issuer and OIDC client
  from window.location.hostname. Every triple was verified against the live
  /v1/iam/oauth/authorize (302 = registered, 400 = not):

    id.bootno.de  bootnode-platform  bootno.de/auth/callback           302
    hanzo.id      hanzo-web3         web3.hanzo.ai/auth/callback       302
    lux.id        lux-cloud          lux.cloud/auth/callback           302
    zoolabs.id    zoo-cloud          zoo.cloud/auth/callback           302
    zoolabs.id    zoo-cloud          cloud.zoo.network/auth/callback   302
    zoolabs.id    zoo-web3           cloud.zoo.network/auth/callback   400  ← was shipping

  Deleted, because all three were the same defect — a value picked at deploy
  time while ONE deployment serves five hosts:
    • public/config.json + config.example.json (the shipped catalog had the
      400 client above, and named apiUrls whose hosts do not resolve)
    • the Dockerfile's NEXT_PUBLIC_BRAND / _API_URL / _WS_URL, which were also
      inert — the runtime is the Go static binary, which never reads env
    • four near-identical Deployments differing only in those variables, now
      one, with the nine ingress rules pointing at the single service
    • NEXT_PUBLIC_AUTH_MODE and four NEXT_PUBLIC_* docs URLs

  Matching is exact-or-dot-suffix, never substring: `includes("lux.cloud")`
  hands lux.cloud's OIDC client to lux.cloud.attacker.example.

  Each brand also gets its own mark. public/logo/{brand}-icon.svg existed for
  all five and nothing used them — every brand pointed at one shared
  /logo.svg. bootnode's is fill="currentColor" and lux's is fill="white",
  which as an <img> (an isolated document) render invisible on black and on
  white respectively, so those two are painted in the current text colour via
  a CSS mask — which is what lux.cloud does with its own pyramid.

FIXED ALONG THE WAY
  • AuthProvider's local getApiUrl() shadowed the import at the top of the
    file and its last line called itself — infinite recursion on localhost,
    127.0.0.1, 192.168.* and 10.*. Every checkAuth/login/register in local
    development overflowed the stack. Deleted; lib/api's resolver is the one.
  • The console nav could not reach eight of its own pages —
    /dashboard/settings, assets/{tokens,nfts}, infrastructure/{lux,services,
    observability}, /chains, /agents rendered and were linked from nowhere.
  • No row was ever marked current: the image serves clean URLs as directory
    indexes, so the pathname is `/dashboard/` and an equality test never
    matched. Nested routes also lit two rows at once. Both are one selection
    rule now — longest matching href wins.
  • Two dead links in the chrome: /products (a directory of pages with no
    index — the static server answers it with the home page) and
    /docs/tutorials (a plain 404).

TESTS — 26, node:test with native TS stripping, no new dependency
  The five-host contract including the substring-matching attack; the nav
  reaching every built route; the trailing-slash and most-specific-row rules;
  and a whole-source dead-link check that derives routes by walking app/ so it
  cannot go stale. That check holds the shell at ZERO dead links and ratchets
  the 36 that predate this work in the docs and product pages — a test that
  fails on the day it is written gets deleted, one that stops the bleeding
  gets kept.

VERIFIED
  pnpm build exit 0, 77/77 pages, route set byte-identical to the baseline
  taken before any change. In a real browser against the built static export:
  body #000 / #f5f5f5, --border rgba(255,255,255,.08), header 57px sticky
  blur(24px), sidebar 236px sticky at top:57px, exactly one active row with
  #1a1a1a fill and aria-current, the phone strip 7% of the viewport scrolling
  inside itself with the body never scrolling sideways, the sheet opaque with
  no nested backdrop-filter, and the favicon, mark and "Sign in with Bootnode
  ID" all coming from the resolved brand.

Not deployed. No page bodies touched.
2026-07-26 15:32:42 -07:00
zeekay 39cb316a15 merge bootnode/bootnode into this history — one repo, both halves
github.com/bootnode/bootnode is the canonical home. These two diverged from
766da81c with real work on BOTH sides — 21 commits there, 26 here — so this is
a merge, not a fork-kill. Deleting either side would have destroyed shipped
work.

From CANONICAL (bootnode/bootnode):
  · Base/SQLite storage, Postgres coupling dropped (5650324)
  · Cluster + Node models — models this side never defined
  · native CI: hanzo.yml, zero .github/workflows (a698acc, aff0952)
  · images to ghcr.io/bootnode/bootnode, its own org (9d0957c)
  · luxd fleet dashboard, k8s validator detector

From HERE (hanzoai/bootnode):
  · HIP-0111 IAM: SPA PKCE, JWKS token validation (ed27226),
    JWT audience enforcement (49eb8cb) — security features canonical lacks
  · runtime white-label brand catalog (422f8f1 + today's work)
  · static-export build (852bb7c)
  · today: toolchain pinned, static export via hanzoai/static, one API
    resolver, honest error states

Seven conflicts, each resolved on evidence, not preference:

  web/lib/brand.ts        OURS   — canonical still hardcodes
                                   bootnode → iam.url "https://hanzo.id",
                                   which is the "Sign in to Hanzo ID" leak
                                   fixed today. It also carries a duplicate
                                   `skillsOrg` field.
  web/Dockerfile          OURS   — canonical runs `pnpm config set
                                   dangerously-allow-all-builds true` AND
                                   `--no-frozen-lockfile`: disables the
                                   supply-chain guard and ignores the lockfile.
  db/models.py            THEIRS — has Cluster + Node and no Postgres dialect.
  db/__init__.py          THEIRS — matches those models.
  .github/workflows/*     DELETED— canonical already went native.
  api/bootnode/main.py    BOTH   — took `__version__` from canonical (better
                                   than the hardcoded "2.0.0" here) and kept
                                   the conditional `docs` from b06f09f
                                   (canonical re-adds "docs": "/docs", a URL
                                   production disables, so it 404s).

This drops a386c9b, the Node/MPCWalletModel commit written earlier today. Its
problem did not exist on the canonical side: those models are already there,
and there is no MPC router to need MPCWalletModel. It was written against the
storage layer canonical replaced in June.

Verified on the merged tree, not assumed: `import bootnode.db` succeeds and
exports 14 models, all 27 API routers import, 0 postgresql dialect imports
remain, web tsc reports 0 errors and the production build exits 0 with
localhost:8000 in 0 chunks.
2026-07-26 14:18:12 -07:00
zeekay baddf8096e fix(api): the db package could not import — define the models it declares
`bootnode/db/__init__.py` imported and re-exported three names that models.py
does not define, so `import bootnode.db` raised ImportError on main. Every
consumer died with it: the e2e job's init_db(), the nodes API, the MPC API, the
keys API, the seed. Prod is unaffected only because it runs image 1.0.1, built
before this landed.

It was invisible because test-api ran

    pytest -v --tb=short || echo "No tests yet"

The `|| echo` makes the step exit 0 unconditionally, so CI reported GREEN while
every test module failed to import. That mask is removed; CI can fail honestly.

The consequence was not subtle: docker-release triggers on
`workflow_run: ["CI"] completed`, CI was red, so the image build was SKIPPED
every time — which is why the deployed web image sat at 0.0.1-web from June
while main moved on.

  Cluster        → renamed OrgCluster at some point; call sites updated.
                   The `"kind": "Cluster"` CRD strings are correct and untouched.
  Node           → never existed in ANY revision (checked back to 2026-01-26).
                   Defined here from the contract the code already declares.
  MPCWalletModel → likewise. Distinct from SmartWallet, which is ERC-4337
                   account abstraction, not threshold signing.

Design, in the terms asked for:

DECOMPLECTED — Node stores DESIRED state only. Sync progress, peer count, block
height, cpu/memory, uptime and the backing pod are facts about the cluster, and
fleet_manager already reads them from the Cluster CR. A copy in this table would
be a second source of truth, stale the instant it is written. That split is why
the model is small; it is the spec half of the spec/status pair the CRD layer
already uses, not a new idea.

TYPED — status/node_type/sync_mode are closed Enums at both the Python and the
column level, not free strings. This class of bug was live: the seed wrote
"running" while NodeStatus offers pending/init/starting/bootstrapping/healthy/
unhealthy/terminated — provably DISJOINT sets, so the dashboard's
`filter(n => n.status === "running")` could never match a backend value and
"active nodes" was structurally always 0. One vocabulary now, defined once in
fleet_models and imported everywhere; seed and both frontend call sites moved
onto it.

SMALL INTERFACE — two properties (`is_serving`, `slug`) and a repr. Everything
else is a column read. No cleverness to undo later.

Flagged, not silently reproduced: MPCWalletModel.encrypted_shares holds key
material in the database. It is encrypted, but the standing rule is that secrets
live in KMS. That column is a migration target, documented in the model.

Verified: `import bootnode.db` succeeds, all three enums resolve, every seed
status value is a member of NodeStatus, web tsc 0 errors, web build exits 0.
2026-07-26 13:49:23 -07:00
zeekay 74c6042671 fix(web): a failed load is an error, not a zero
Across the dashboard, every data fetch was `.catch(() => null)` followed by
`res?.ok ? await res.json() : []`. A 401 after token expiry, a CORS refusal, a
5xx, or the API being unreachable all became empty data — and the pages rendered
"0 API keys / 0 webhooks / 0 networks / 0 team members" with no error state at
all. A user cannot tell an empty account from a broken one. Only
console.error() recorded anything, where nobody looks.

Adds `load()` / `loadError()` to lib/api.ts — one helper returning a
discriminated `{ok: true, data} | {ok: false, why}` — and routes the dashboard,
analytics, organization and networks pages through it. Each now shows a banner
naming which sources failed and why, while still rendering whatever DID load, so
one dead endpoint no longer blanks a page. The codebase already knew better:
infrastructure/nodes/page.tsx has used setError on both branches all along.

Monitoring needed the opposite treatment and was mostly already correct: it is a
health page, so its `.catch(() => null)` on /health is RIGHT — null falls to an
else branch that pushes an explicit "down / Unreachable" row, and the 3s probe
loop at line 125 likewise reports a failed fetch as "degraded". Not every
swallowed error is a defect; there the failure IS the datum. The one gap was the
nodes list, which silently emitted [] and made an unreachable API look like an
empty fleet — it now pushes a "down" row like the API check above it.

tsc 0 errors, production build exits 0, 0 chunks contain localhost:8000.
2026-07-26 13:21:23 -07:00
zeekay 4bd5ae988f 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.
2026-07-26 13:07:45 -07:00
zeekay e09e86f940 fix(web): per-host apiUrl/wsUrl in the catalog, where they can actually resolve
The bootnode-web CR set NEXT_PUBLIC_API_URL=https://api.web3.hanzo.ai and
NEXT_PUBLIC_WS_URL=wss://ws.web3.hanzo.ai. The image is a static export served
by the Go hanzoai/static binary, so nothing reads container env — the live
bundle carried the build-time default api.bootno.de and ws.bootno.de instead.
The CR promised one thing and the app did another, on every host it serves.

Exactly the failure already fixed for the IAM knobs in this file: one image,
many white-label hosts, so a build-time NEXT_PUBLIC_* is a single global that
cannot express per-host anything. The brand catalog can, because it resolves
from the hostname at runtime.

web3.hanzo.ai now gets its own API and WS origins. Verified the two backends
are the same service before repointing: `/` and `/v1/chains` return
byte-identical bodies on api.bootno.de and api.web3.hanzo.ai, and both answer
401 on /v1/auth/me; ws.web3.hanzo.ai serves.

bootno.de keeps api.bootno.de via the built-in default. pars and zoo are left
alone deliberately: api.pars.network and api.zoo.network exist, but nothing
declared them as those brands' backends and cloud.zoo.network does not resolve
at all — I am not inventing a binding I cannot verify. (Note for whoever does:
lib/docs-config.ts derives `api.${brand.domain}`, which yields the nonexistent
api.cloud.pars.network — that derivation is wrong for the cloud.* hosts.)
2026-07-26 13:03:45 -07:00
zeekay 5c7e9fd3bd build(web): serve the static export via hanzoai/static, not a Node server
The image build could not succeed at all. next.config.ts declares
`output: "export"`, which emits ./out and never emits .next/standalone, but the
Dockerfile's runner stage did:

  COPY --from=builder /app/.next/standalone ./
  CMD ["node", "server.js"]

so every build failed with `"/app/.next/standalone": not found` after compiling
successfully. Two mutually exclusive deploy models in one repo; the deployed
image simply stayed frozen at its last pre-`export` build.

One model now — the static export, served by ghcr.io/hanzoai/static:v0.5.1
(scratch + a single Go binary; no nginx, no caddy, no Node in the runtime
image), matching hanzo.ai/Dockerfile.production.

Port stays 3001 via static's --port flag, so the existing contract is untouched:
the bootnode-web App CR declares containerPort/servicePort 3001 and the
hand-made bootnode-web-ingress routes to bootnode-web:3001 by number.

Includes the deep-link fix the house template documents: the export is flat
(out/pricing.html), static serves a directory's index.html, and in --spa mode a
miss on extensionless /pricing falls through to the homepage — so each page also
gets materialized as out/pricing/index.html.

Also stops tracking web/tsconfig.tsbuildinfo, a build artifact that was dirtying
the tree on every typecheck.
2026-07-26 10:35:31 -07:00
zeekay 4b9de98537 build(web): pin the toolchain and bump every dependency to latest
The repo could not be built at all. Two independent faults, both from an
unpinned toolchain:

1. `corepack enable pnpm` with no `packageManager` field fetched whatever pnpm
   was newest at build time. pnpm 11 REMOVED `onlyBuiltDependencies` (replaced
   by `allowBuilds`) and made `strictDepBuilds` default to true, so the sharp
   allowlist that package.json still carried was silently ignored and install
   died with ERR_PNPM_IGNORED_BUILDS. pnpm 11 also demotes `.npmrc` to
   auth/registry only, so settings must live in pnpm-workspace.yaml.

   Fixed by pinning `packageManager: [email protected].0` and moving the setting to
   pnpm-workspace.yaml in its current form (`allowBuilds: {sharp: true}`).

2. The `pnpm` field in package.json is dead weight under pnpm 11 — removed
   rather than left to rot as a second, ignored source of the same setting.

Dependencies taken to latest, including four majors:

  typescript      5.9.3   (NOT 7.0.2 — see below)
  zod             3 -> 4
  recharts        2 -> 3
  lucide-react    0.544 -> 1.26
  next            16.0.1 -> 16.2.11
  react/-dom      19.2.0 -> 19.2.8
  @hanzo/iam      0.11 -> 0.13.8
  tailwindcss     4.1 -> 4.3.3
  @types/node     22 -> 24 (aligned to the runtime, not bumped past it)
  + every Radix, TanStack and support package

TypeScript is deliberately held at 5.9.3, the newest that works. 7.0.2 is the
Go-native rewrite with a different package shape; Next 16.2 cannot resolve it
and the build fails after compiling, in Next's TypeScript step, with
`The "id" argument must be of type string. Received undefined` after it tries
to reinstall typescript itself. Revisit when Next declares support.

Base image node:22-alpine -> node:24-alpine (Active LTS; 26 is Current, not
LTS), digest-pinned so it cannot drift the way pnpm did. pnpm 11 needs Node
>= 22 regardless.

Verified locally, not assumed: `pnpm install` exits 0, `tsc --noEmit` reports
0 errors across the whole bumped tree, and `pnpm run build` exits 0 with every
route prerendered.
2026-07-26 10:32:54 -07:00
zeekay b53533db53 fix: drop the dangling skills gitlink that made the repo unbuildable
`skills` was committed as a gitlink (mode 160000, a0665829) but the repo has
no .gitmodules, so the entry has no URL. Any checkout that initialises
submodules dies on it:

  fatal: No url found for submodule path 'skills' in .gitmodules

BuildKit's git source always updates submodules, so this failed EVERY
platform-native build of this repo before it could even read the Dockerfile.
That is why the deployed bootnode-web image still dates from June while main
moved on.

The path is an empty directory with no upstream recorded anywhere, so the
gitlink points at nothing recoverable — removing the index entry is the whole
fix. If a skills submodule is wanted later it needs a real .gitmodules URL.
2026-07-26 10:23:52 -07:00
zeekay c289ba602c 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.
2026-07-26 10:21:14 -07:00
zeekay 5d1620ab8e fix(api): stop advertising a /docs URL that production disables
The FastAPI app deliberately turns off docs/redoc/openapi in production:

    docs_url="/docs" if not settings.is_production else None

but the root endpoint returned `"docs": "/docs"` unconditionally. So the
public API root promised a URL that 404s — api.bootno.de/ says
{"name":"Bootnode API","version":"2.0.0","docs":"/docs","status":"operational"}
while /docs, /redoc and /openapi.json all return {"detail":"Not Found"}.

Anyone reading the root to find the schema followed a dead link, and the
disabled-in-prod decision was correct, so the answer is to tell the truth
rather than re-enable them.

root() now mirrors app.docs_url and omits the key entirely when docs are off,
so the two cannot drift again — there is no second place to remember when the
setting flips. Verified both ways: with docs_url None the key is absent, with
"/docs" it is present.
2026-07-26 10:03:31 -07:00
zeekayandHanzo Dev 37b6492d08 iam: canonical endpoints; drop the hand-rolled login button
The API resolved JWKS at {iam_url}/.well-known/jwks.json and exchanged
codes at {iam_url}/oauth/token — measured 401 on both; the canonical
/v1/iam/.well-known/jwks and /v1/iam/oauth/token answer.

brand.json pointed at iam.bootno.de, which does not resolve (525). The
brand's canonical issuer is id.bootno.de, already what web/lib/brand.ts
uses.

HanzoLoginButton hand-rolled a /oauth2/authorize URL that exists on no
IAM deployment. It was imported but never rendered — the login page has
used getIam().signinRedirect() (@hanzo/iam PKCE) since HIP-0111 — so it
is deleted rather than repointed. One way in.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 13:34:40 -07:00
zeekay 537449038e feat(fleet): server-side admin gate on the luxd fleet overview
Deny-by-default on the ONE cross-tenant infra route, enforced on both the
standalone fleet_app and the mounted api/fleets router:
  unauthenticated -> 401 · invalid token -> 401 · non-admin -> 403 · admin -> 200

Adversarial test exercises the gate end-to-end over real HTTP; only JWKS
verification and the luxd RPC collector are stubbed.
2026-07-25 12:35:14 -07:00
zeekay d5fbe67fad feat(lux): graphical fleet dashboard at lux.cloud
Turn lux.cloud into a live graphical fleet dashboard for the managed luxd
fleet (devnet/testnet/mainnet, 5 validators each on do-sfo3-lux-k8s).

Backend (read-only collector, no mutations, no monolith dependency):
- core/chains/lux_fleet.py: reads the operator LuxNetwork CR (lux.cloud/v1)
  for validators/version/plugin-set/phase, lists luxd-N pods + per-pod LB
  Services, and queries luxd JSON-RPC per node (info.getNodeVersion/getNodeID/
  peers/isBootstrapped, C-Chain eth_blockNumber, P-Chain platform.getHeight),
  plus a native D-Chain DEX summary. Aggregates all 3 nets concurrently.
- fleet_app.py: minimal standalone FastAPI exposing GET /v1/fleets/lux/overview
  (public, read-only). Reuses the bootnode api image; isolated from the
  (currently import-broken) monolith.
- fleets/__init__.py: same overview endpoint also mounted on the main router.
- Uses a READ-ONLY lux-k8s kubeconfig from KMS; only public chain data leaves.

Frontend (Next.js 16, Lux white-label, auto by hostname):
- lib/fleet.ts: typed client + health/status derivations.
- components/lux/fleet-dashboard.tsx: graphical multi-net dashboard — totals,
  world topology map, per-network cards (phase, version, plugin-set, peers,
  C-Chain height, DEX state, per-chain C/P/X/D bootstrap), expandable per-node
  tables (node ID, version, peers, heights). Auto-refresh 15s.
- app/fleet/page.tsx: public /fleet page (the lux.cloud fleet dashboard).
- components/lux/fleet-strip.tsx + page.tsx: live fleet status strip on the
  landing hero.
- navbar + dashboard nav: Fleet links; register existing Lux fleet mgmt page.
- brand.ts: add skillsOrg (fixes pre-existing typecheck/build break).

Deploy: infra/k8s/lux-fleet.yaml — lux-fleet Deployment/Service + ExternalSecret
(lux-k8s RO kubeconfig from KMS) + ingress path /v1/fleets/lux/overview on
api.cloud.lux.network.

Verified: collector live against all 3 nets (real heights/peers/versions/DEX);
api + collector tests pass (8/8); web typecheck + production build green;
/fleet renders live data in browser (screenshot).
2026-07-25 12:35:14 -07:00
zeekayandHanzo Dev 2307ce8f48 chore(kms): retire Node hanzoai/kms → luxfi/kms:1.11.9 in local-dev compose
Swap the local-dev KMS onto Go/ZAP luxfi/kms (embedded ZapDB): ZAP_PORT=0
(secrets-only — v1.11.x gates the consensus authorizer behind ZAP, so no
consensus/luxd needed locally), fixed dev KMS_MASTER_KEY_B64 /
KMS_ENCRYPTION_KEY_B64, /healthz healthcheck, kms_data volume. Drops the
Node env (DB_CONNECTION_URI/ENCRYPTION_KEY/AUTH_SECRET/SITE_URL) and the
Postgres/Redis deps only the Node kms used (both DBs kept for other services).
Live prod (luxfi/kms:1.11.9) untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-19 00:49:24 -07:00
zeekayandHanzo Dev 6b0e4399de fix(cors): allow lux.cloud + console.lux.cloud origins
lux.cloud is the canonical Lux web3 console; its RPC & API-Keys section calls
the control plane (api.cloud.lux.network) from the browser. The production CORS
allowlist had cloud.lux.network but not lux.cloud, so key create/list/revoke
preflights 400'd. Add the canonical console origins to production_origins.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-15 23:09:59 -07:00
zeekay 46ebd33d0e merge platform/web3-iam-hip0111-convergence to main 2026-07-01 06:33:10 -07:00
zeekay 5aeb6ec6b9 Merge branch 'platform/web3-iam-hip0111-convergence' 2026-06-30 14:32:59 -07:00
zeekay 532e89b3ad docs(brand): add hero banner 2026-06-28 20:10:22 -07:00
zeekay 880beacbcb chore(brand): dynamic hero banner 2026-06-28 20:10:21 -07:00
zeekayandGitHub 36ade30e19 Web3/Bootnode: converge on @hanzo/iam (HIP-0111) — SPA PKCE FE + JWKS backend (#8)
* feat(web3): restore canonical @hanzo/iam SPA PKCE auth (HIP-0111)

The brand-neutral refactor regressed login to a hand-rolled OAuth authorize
URL (no PKCE, wrong path `${iamUrl}/oauth/authorize`) plus a backend
code-exchange POST to /v1/auth/oauth/callback — a route api-go does not
expose. Both break IAM login: the hand-rolled URL hits a path IAM serves
its 200 SPA catch-all for (silent breakage), and the callback 404s.

Restore the one canonical way per HIP-0111:
- lib/iam.ts: @hanzo/iam/browser IAM client, white-labelled by the active
  brand (serverUrl + clientId), PKCE S256, endpoints resolved via OIDC
  discovery. No hand-rolled authorize URL, no genericOAuth, no /api/auth.
- login: getIam().signinRedirect() in production (SDK owns PKCE + state).
- callback: getIam().handleCallback() does the PKCE token exchange
  client-side; the IAM access token becomes the session bearer.
- brand default: bootnode IAM is id.bootno.de / bootnode-platform (was
  hanzo.id / bootnode-web — wrong issuer + unregistered client).
- package.json: add @hanzo/iam ^0.11.0 (used but missing from deps).
- public/config.example.json: in-repo reference for the per-brand catalog
  (registered clientId per host) so the deploy provisions real clients.

Build + typecheck green.

* feat(api): validate IAM tokens via JWKS in production (HIP-0111)

The api-go backend only had an HS256 verifier (shared JWT_SECRET). After the
FE completes IAM PKCE it presents an IAM-issued RS256 access token, which the
HMAC verifier rejects (SigningMethodHMAC check) — IAM login fails at the API.

Converge on the canonical IAM validation path:
- iam/jwks.go: JWKSVerifier validates RS256/ES256 tokens against the issuer's
  JWKS, resolved via OIDC discovery ({issuer}/.well-known/openid-configuration
  -> jwks_uri). Keys cached, refreshed on unknown kid (rate-limited). stdlib
  only — no new dependency. Issuer is checked (WithIssuer).
- main.go: production selects JWKSVerifier(IAM_URL); dev keeps HS256 for the
  built-in email/password flow. One way per environment.
- config: ValidateSecrets() fails fast if production boots with placeholder/
  empty JWT_SECRET or API_KEY_SALT — secrets MUST come from KMS, never the
  in-code default. IsProduction() gates it.
- iam.go: drop redundant exp/iat int64 fields from Claims — duplicate json
  tags shadowed jwt.RegisteredClaims and silently disabled expiry validation
  (latent bug in the HS path too; now exp is enforced).

Tests: JWKS valid/expired/wrong-issuer/unknown-kid/HS-rejected + config
secret guard. go build + go vet + go test all green.

* harden(api): enforce JWT audience when IAM_CLIENT_ID is set

Adversarial review follow-up: without an aud check, a token minted for ANY
client of the same IAM issuer would be accepted. NewJWKS now takes the
expected audience (the deployment's IAM_CLIENT_ID); when set, tokens whose
aud does not match are rejected (token minted for a different client is no
longer accepted). Empty audience preserves issuer+signature-only behavior.

Test: matching aud passes, mismatched aud rejected.

* fix(web): sync lockfile for @hanzo/iam + remove invalid pnpm-workspace.yaml

test-web failed at `pnpm install --frozen-lockfile` for two reasons:

1. web/pnpm-workspace.yaml carried only `allowBuilds: {sharp: true}` — not a
   valid pnpm 9 field, and a workspace file without a `packages:` key makes
   pnpm 9 abort with ERR_PNPM_INVALID_WORKSPACE_CONFIGURATION (pre-existing
   on main). web/ is a single package, not a workspace, so the file is removed
   and sharp's build allowance moved to package.json `pnpm.onlyBuiltDependencies`.

2. This branch added `@hanzo/iam@^0.11.0` to package.json but never regenerated
   pnpm-lock.yaml, so --frozen-lockfile rejected the out-of-date lock.

Regenerated the lockfile (pnpm 9.15.9) — it now pins @hanzo/[email protected].0. Verified
locally: `pnpm install --frozen-lockfile` is clean and `pnpm run build`
(next build) compiles all routes incl. the refactored /login + /auth/callback.

---------

Co-authored-by: zeekay <[email protected]>
2026-06-25 15:13:17 -07:00