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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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.
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.
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]>
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]>
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.
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.
`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.
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.
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.
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.)
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.
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.
`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.
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.
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.
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]>
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.
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).
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]>
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]>
* 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]>
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.
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.
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.
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.
bootnode org has 0 self-hosted runners, so the reusable hanzoai/ci
workflow (runs-on the org-scoped arc pool) queued forever and never
built anything. Remove .github/workflows/cicd.yml and let platform.hanzo.ai
own build+test off this repo's hanzo.yml: its GitHub App delivers push/PR
to the platform webhook, which builds each image on the shared in-cluster
hanzo-build-* arcbuild pool and pushes ghcr.io/bootnode/bootnode:<sha>-amd64-<suffix>.
Rollout stays GitOps (luxfi/universe), not platform — the Deployment-style
deploy.services form is build-only to the platform. One canonical path.
Bootnode could only see nodes it launched as Docker containers; real Lux/Zoo
validators run as K8s workloads behind operator CRDs and were invisible to
GET /v1/nodes. Add a detector that scans a cluster's kubeconfig and adopts
its running validators into the DB.
- core/k8s_nodes.py: K8sNodeDetector scans LuxNetwork (lux.network/v1alpha1
luxnetworks) + ZooChain (zoo.network/v1alpha1 zoochains) CRs across all
namespaces via CustomObjectsApi, enriches each with its luxd/zood pods for
live status, and upserts Cluster + Node rows. Adoption, not recreation:
existing rows (keyed on (cluster_id, pod_name)) are updated in place, so
re-scans are idempotent. New operator chains are one descriptor row.
- db/models.py: add Cluster + Node models (portable JSON/Uuid types, matching
the existing db_nodes.py / seed_nodes.py consumers that imported them).
- api/nodes: merge DB-adopted k8s nodes into list_nodes() alongside Docker
discovery; add POST /v1/nodes/detect and POST /v1/nodes/clusters/{id}/scan
(kubeconfig resolved via the shared fleets path). Reuses existing get_db.
- pyproject: add aiosqlite (config.database_url now defaults to
sqlite+aiosqlite; the driver was missing).
Detector adopt/idempotency + model mapping verified against in-memory SQLite
with faked LuxNetwork/ZooChain CRs and pods; full app OpenAPI builds with the
new routes; 112 existing tests pass (pre-existing test_lux_fleet /
test_chain_config failures unchanged).
bootnode stores its catalog in Base/SQLite — the canonical store, durable via
VFS→SeaweedFS — not Postgres. Make the models dialect-portable and the engine
sqlite-aware:
- models.py: postgres JSONB/UUID → portable sqlalchemy JSON/Uuid
- config.py: database_url is str (not PostgresDsn) so sqlite+aiosqlite:/// DSNs
validate; default = sqlite+aiosqlite:////data/bootnode/bootnode.db
- session.py: normalize sqlite:// → sqlite+aiosqlite://, and skip the sized
pool (pool_size/max_overflow are invalid on sqlite) + check_same_thread off
Postgres DSNs still accepted (legacy/multi-instance) but no longer required.
bootnode is its own GitHub org — its images belong in its own GHCR namespace,
per the never-mix-registries rule. This also removes cross-org friction: the
repo's own GITHUB_TOKEN pushes to ghcr.io/bootnode/* (same org), so once the
arcd App is on the bootnode org, build+push needs no cross-org token.
Replaces .platform.yml / docker-release.yml / platform-build.yml / ci.yml with:
- hanzo.yml (root): declarative images + test + deploy, read by the workflow
and by platform.hanzo.ai. Multi-image (api+web), unlike .platform.yml.
- .github/workflows/cicd.yml: runs on our self-hosted arc runners
([self-hosted, linux, amd64]) — offloaded from GitHub-hosted. Pulls GHCR +
kubeconfig creds from KMS via Universal Auth at run time (only secrets in
GitHub are KMS_CLIENT_ID/SECRET + var KMS_WORKSPACE). Builds + tests + rolls
the images onto the cluster. Self-contained — no cross-org reusable workflow.
One way: every repo gets the same cicd.yml and declares its specifics in hanzo.yml.
ghcr.io/luxfi/node:latest is amd64-only; under qemu on arm64 the out-of-process
C-Chain EVM plugin can't complete its go-plugin handshake. The v1.30.14-dchain
tag is multi-arch (amd64+arm64) — verified: arm64 build boots natively, C-Chain
eth_chainId=0x7a69 (31337), no qemu. lux/hanzo/zoo now have working C-Chains on
both local (arm64) and DOKS (amd64).