The org IS the tenant (a user's personal/default org included): each IAM org gets
one physically-isolated Base, served natively same-origin by the ONE cloud binary
at /v1/base, scoped by the validated IAM principal (CLOUD_BASE_EMBED). Delete the
incidental complexity — the SuperBase 'tenants' collection + <slug>.base.hanzo.ai
orchestrator (BasesManager, tenants.ts, bases-logic). BaseModule now shows the
current org's Base (its content types); switching tenant = switching org (the
sidebar org switcher). RecordsModule transport -> /v1/base (was /v1/superbase, 404
on cloud.hanzo.ai). Verified: /v1/base/collections = 200 on cloud.hanzo.ai.
/agents showed empty Status/Logs/Metrics despite agents + runs existing: the product
only owned the '' (Overview) route, so those base slugs fell to the shared subpage system
which reads o11y (not wired for agents) + the usage ledger (no product:agents-tagged spend)
→ honest-empty. But the data IS there in /v1/agents (invocations, health, activity).
Agents now OWNS Status/Logs/Metrics (registry subpages + :tab route, same pattern as
Inference). AgentsModule renders a focused slice per tab from its OWN derived data:
Metrics = counts + invocation trend + resource usage; Status = health donut + agents table;
Logs = the invocation activity feed; Overview = all. Settings stays the shared subpage.
tsc clean (my files); the AppLauncher/brands tsc errors are pre-existing main, not this.
isAdminHost(host) gate: the operator cockpit keeps its silent-SSO bounce; only
consumer hosts (cloud/console/tenant) show the marketing landing at / for anon.
Unauthenticated visitors at / get a marketing page (gather interest + explain the
product) instead of a bounce to /signin — served by the SAME one cloud binary that
serves the signed-in console (no separate marketing service). AuthGate gains a
'landing' surface: anon-at-/ → PublicLanding, signed-in-at-/ → console. Landing
content is derived from the real taxonomy (categoriesForBrand + CATEGORY_SUMMARY),
brand-scoped via getBrand, with the ONE sign-in CTA (/signin).
Expand the bottom Developers dock from 3 tabs to the full developer surface,
each org-scoped, wired to REAL data or an honest empty/coming/runtime state,
each mounted only while active (lazy):
- Overview enhanced: request volume + error rate + tokens/spend (charged
ledger) + the account Cloud API key (KeysApi) + API v1 + dev-resource links.
- Logs filterable + row -> JSON detail (real usage ledger).
- Events platform-event stream projected from the same real ledger.
- Webhooks event destinations: real rows when the endpoint API is live, else
the honest create-first-destination state (forward-compatible).
- Health Alerts/Errors/Insights from the o11y runtime (ApmApi + o11y rules;
honest RuntimeNotice when o11y isn't routed for the org).
- Inspector fetch any object by id -> JSON + related ledger activity.
- Traces the existing TracesModule embedded.
- Shell enhanced: resource picker + show-code (curl/CLI).
Tab bodies live in tabs.tsx; Workbench.tsx is the dock shell owning the shared
usage fetch. Preserves the collapse/expand + Developers bar affordance and the
existing e2e contract (bar text, Overview/Logs/Shell labels, shell aria-label).
Extend the workbench logic (node-testable, no React/gui imports):
- inspectorRoute(id): id-prefix (agent_/fn_/flow_/run_/prompt_/trace_, hk-/sk-/pk-)
or raw resource/name path -> the same-origin /v1 GET; URL/traversal refused.
- curlFor/hanzoCli: the same read as curl (Bearer hk-) and the Hanzo CLI.
- eventsFrom(records): project the real usage ledger into a platform-event stream.
+8 tests.
'Apps button / ⌘K doesn't show all apps.' ROOT: both browse-all surfaces were braided
with entitlement — AppLauncher passed the org's `enabled` set to visibleCatalogByCategory
and filterEntitled, and ⌘K passed it to searchDestinations, so a non-superadmin org only
saw its always-on + enabled subset (the launcher 'mirrored the sidebar scope').
DECOMPLECT: the Apps launcher and ⌘K are DISCOVERY surfaces — 'browse ALL apps' means the
WHOLE catalog. Entitlement is a property of USING a product (the sidebar = your workspace
nav, and the product page's honest 'enable for your org' state), NOT of SEEING it in the
directory. So both now render visibleCatalogByCategory(showAdmin, null) / searchDestinations
(query, showAdmin, null) — full catalog, admin-gated only (internal operator surfaces stay
behind showAdmin). The sidebar + product-use remain entitlement-scoped, unchanged.
tsc clean; 180 product/entitlement tests green (the filterEntitled/entitledSet functions are
untouched — still used by the sidebar; only the discovery callers stop scoping).
AppLauncher's cross-surface tiles now come from the ONE @hanzo/ui surfaces list
(otherSurfaces('console') = every surface but this one), each with a distinct
icon — replacing the hardcoded team+billing pair. Bumps @hanzo/ui to 8.0.6.
hanzo-build-linux-amd64 arc pool is repaired (recreated scale-set cleared the stuck
GitHub message session that was thrashing runners; restored the custom runner image
and the shared arc-github-secret). Drop the temporary hanzo-deploy override.
console.hanzo.ai silent-SSO-looped: the hanzo-cloud IAM app issues tokens with no
configured lifetime, so the token response omits expires_in. @hanzo/iam@0.13.6
storeTokens() only writes hanzo_iam_expires_at WHEN expires_in is present (no else),
so isTokenExpired() returns true forever → getValidAccessToken() returns null → a
freshly-minted token reads as expired → endless authorize redirects (~128/min).
patch-package adds the missing else to all 6 SDK bundles: when expires_in is absent,
fall back to the access token's own JWT exp claim (RFC 7519) — authoritative for IAM's
JWTs, correct on the initial exchange AND every refresh. + patch-package postinstall so
CI applies it on npm ci. (SDK repo is badly diverged from the published 0.13.6, so a
republish would regress; patching the exact published bundle is the zero-regression fix.)
hanzo-build-linux-amd64 runners are stuck (empty-image drift on the live
AutoscalingRunnerSet + a duplicate listener; ephemeral runners churn Pending
without consuming the queued jobs), wedging every hanzoai build on that label.
Point the console CI/CD at the healthy hanzo-deploy-linux-amd64 pool so the
console-embed artifact (carrying the login-loop fix e24869c3f) rebuilds now.
Temporary — revert to the default pool once hanzo-build is repaired.
The @hanzo/iam SDK's getValidAccessToken() returns null when the token-exchange
response carried no `expires_in` (the hanzo-cloud IAM app has no token lifetime set)
and no refresh token was issued — it flags the freshly-minted token expired even
though IAM accepts it (userinfo 200). That null dead-ended AccountApi.session() ->
account=null and looped /signin (~128 POST /login/oauth per minute). Fall back to the
raw stored token at the wrapper boundary; account.ts and the refresh timer are
unchanged (expiry still derived from the token's own exp).
console.hanzo.ai is a Next static export served as an SPA shell — the Go embed
serves the / route's index.html (the (dashboard) tree, guarded by <AuthGate/>)
for EVERY path (/, /signin, /auth/callback are byte-identical). A hard nav to
/auth/callback?code=… therefore mounts <AuthGate/>, NOT app/auth/callback/page —
AuthGate special-cased /signin but not the callback, so it fired
router.replace('/signin') before the PKCE code→token exchange could run. The
?code was discarded and sign-in dead-looped (the console-login P0).
Extend the existing /signin SPA-fallback pattern to the callback: AuthGate now
renders <AuthCallback/> for /auth/callback, completing handleCallback() BEFORE the
guard. Callback logic is extracted into one shared component used by both the
route and the gate (no duplication).
Fixes console.hanzo.ai login never completing.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
iamSdk().getUserInfo() returns null on a 200 in the deployed SDK, so AccountApi.session()
dead-ended before accountFromClaims and looped /signin for everyone. Decode the account
from the access-token JWT directly (always carries >= sub); userinfo is now only a
fallback. Combined with the owner/config-org fallback, a valid token can't dead-end.
Backend (hanzoai/ai) renamed the router-config routes to resource-oriented
nouns and DROPPED the old compound routes (no aliases, no backwards compat).
Move every console caller in lockstep so nothing 404s after ship:
GET /v1/get-router-policy -> GET /v1/router/policy
POST /v1/update-router-policy -> PUT /v1/router/policy (verb -> PUT)
GET /v1/get-routing-defaults -> GET /v1/router/defaults
GET /v1/get-org-settings -> GET /v1/org/settings
POST /v1/update-org-settings -> PUT /v1/org/settings (upsert, PATCH-merge)
POST /v1/delete-org-settings -> DELETE /v1/org/settings
GET /v1/get-org-settings-list -> GET /v1/org/settings/list
- api/router.ts: RouterPolicyApi.get->originGet('router/policy'),
save->originPut('router/policy') (PUT, not POST).
- api/org-settings.ts + api/org-blend.ts: originGet('org/settings'),
originPut/originDelete; the read-modify-write still sends the full row
(safe under the backend's new PATCH-merge PUT).
- next.config.mjs: drop the dead get-/update- heads; 'router' head already
covers /v1/router/*; TARGETED /v1/org/settings* rewrites (an 'org' head would
hijack platform /v1/org/{org}/cluster).
- app/ai/[...path] ALLOWED: swap old exact paths for router/policy +
org/settings + org/settings/list.
- ai-accounts/routing-defaults route: UPSTREAM_PATH -> v1/router/defaults.
- e2e/router-config.spec.ts: GET+PUT dispatched on the one /v1/router/policy
noun; models-surfaces mock -> /v1/org/settings. Doc/hint sweep.
tsc --noEmit clean; next build + build:embed green.
The deployed OIDC /v1/iam/userinfo omits `owner` and `sub` is the user UUID, so
accountFromClaims returned null for every valid session and looped /signin. Resolve
owner: owner/organization claim -> sub-prefix -> config.iamOrgName. Also fall back
name to email. A valid IAM session can no longer dead-end.
accountFromClaims required an `owner` claim, but OIDC /v1/iam/userinfo only
returns sub/preferred_username/name/email (no owner). Every login resolved to
account=null and looped back to /signin. `sub` is `owner/name`, so derive owner
from it exactly as `name` already is.
Re-run scripts/sync-benchmarks.mjs so the checked-in corpus fixture picks up the
enso family (enso-ultra/enso/enso-flash, hanzo-measured) and the provider-reported
relabel from priors/leaderboard.json.
Leaderboard gains a Source filter (All / Hanzo-measured / Vendor-reported) over a
pure sourceClass() classifier in the one reader, badges every row by class
(Enso / Hanzo-measured / reported), and shows the three Enso tiers side by side —
monotonic Ultra 92.9 > Pro 87.9 > Flash 75.8 GPQA, priced from the same corpus.
Honest by construction: enso ranks on merit (never floated to #1), tiers render
only when the corpus carries them, unscored rows stay omitted.
Re-run scripts/sync-benchmarks.mjs so the checked-in corpus fixture picks up the
enso family (enso-ultra/enso/enso-flash, hanzo-measured) and the provider-reported
relabel from priors/leaderboard.json.
Leaderboard gains a Source filter (All / Hanzo-measured / Vendor-reported) over a
pure sourceClass() classifier in the one reader, badges every row by class
(Enso / Hanzo-measured / reported), and shows the three Enso tiers side by side —
monotonic Ultra 92.9 > Pro 87.9 > Flash 75.8 GPQA, priced from the same corpus.
Honest by construction: enso ranks on merit (never floated to #1), tiers render
only when the corpus carries them, unscored rows stay omitted.
Extend the Router > Policy editor (RouterPolicyEditor) with the two org-router
controls the auto-router (Enso) needs, round-tripped through the existing
GET/POST /v1/{get,update}-router-policy:
- Enabled models: a checklist populated from the policy's servable `available`
set (shows name, submits id); Select all / Clear; empty selection = ALL models
allowed (labelled clearly).
- Savings<->quality dial: a labeled 0..1 slider (step 0.05) via FieldSlider;
0 = savings, 1 = quality, 0.5 = balanced; null/unset renders balanced.
Preserves prefer + costCeiling on save. RouterPolicy gains enabledModels /
qualityBias / available (RouterModel). The auto-routing ON/OFF toggle stays a
separate concern on the Smart-routing (ai-accounts) surface — not duplicated.
Playwright-verified (e2e/router-config.spec.ts, 3/3): panel renders, Select-all/
Clear re-count live, Save POSTs enabledModels + qualityBias (contract proof),
mobile no horizontal scroll. tsc clean; vitest 2751 pass; next build + build:embed green.
brands.ts stops duplicating the fleet brand data (lux/zoo/pars identity +
marks were re-typed here) — it now adapts @hanzo/brand/registry's
BrandIdentity onto the console Brand shape. Host resolution stays in ~/config
(SSR default + IAM app wiring untouched → zero auth-path change). Marks are
byte-identical (the registry carries the same console v1 currentColor marks),
so zero visual regression; getBrand() signature unchanged, all 8 consumers
unaffected. Drift-fix: Hanzo orgName 'Hanzo Industries Inc.' -> 'Hanzo AI Inc.'
(matches brand.json/legalEntity; not rendered in console).
typecheck (tsc --noEmit) green. Needs @hanzo/brand@^1.4.0 published + npm
install to update the lock (deploy-gate).
The catalog was driven entirely by the live gateway, so a full backend outage or an
unrouted pricing endpoint left the Models surface empty behind an error card rather
than a browsable list. This adds a versioned fixture, catalog.data.json, synced from
hanzoai/enso-bench priors/openrouter_models.json by scripts/sync-models.mjs and imported
at build time, as the guaranteed base of the one fetchCatalog. The reason it is a fixture
rather than an endpoint is the same reason the benchmark corpus is: it is a versioned
artefact that changes when a catalog sync lands, not per request, so an endpoint would
buy nothing and cost a loading state, a failure mode, and a fabrication risk on every
page view.
Each of the roughly three hundred forty rows carries the model id, vendor, context
window, both sides of the per-Mtok price, and the capability flags read from the prior's
own fields, so the Vision badge and the detail feature chips are derived, never guessed.
No description is emitted because the prior carries none, and an em-dash is more honest
than a fabricated blurb. Benchmark scores continue to come from the enso-bench leaderboard
corpus, which already excludes the degraded blank-heavy runs the summary marks, so a
model without a published score renders an em-dash and never a zero.
fetchCatalog now merges the fixture base with the live rich pricing catalog and the live
routing set in increasing order of authority, so live pricing always wins where it exists
and the fixture only fills what the live catalog omits. It no longer throws when the
gateway is unreachable; it degrades to the fixture with every model honestly marked
Catalog rather than Live. Vendor logos and blend semantics are unchanged.
Replace @hanzo/iam-js-sdk with @hanzo/iam and route the entire client
sign-in through one redirect + PKCE flow where IAM owns every credential
step. Mount <IamProvider> at the root; the sign-in screen is one
"Log in with Hanzo" button (useIam().login()) and /auth/callback completes
the PKCE token exchange (useIam().handleCallback()).
The session provider, account resolution, and API client now read the IAM
identity: the API client carries the IAM access token as a Bearer on every
/v1 call (cloud SanitizeIdentity validates the JWT), the account is
projected from the IAM userinfo claims, and refreshSession delegates to the
SDK's rotating refresh grant. No session cookie, no confidential-client
BFF code->cookie exchange, no ROPC.
Strip the non-IAM login mechanisms:
- inline email/password + social-button form (SignInForm) and its wrapper
- ROPC /v1/iam/login (iam-login) + hand-rolled PKCE (pkce)
- server-driven provider list (providers) and signup BFF (signup)
- the /auth/refresh, /auth/signin, /auth/signup BFF endpoint routes
- the old @hanzo/iam-js-sdk Sdk wrapper
tsc --noEmit clean; next build and build:embed both green.
- OrgSwitcher is now the hoisted @hanzo/ui/product switcher (ui#36 closed):
a thin adapter wires ~/lib/org-scope (the same contract), the lazy
IamAdminApi.organizations pager (super-admin only), the /v1/iam/onboard
create hook, and the All-organizations picker row. One switcher, one home.
- Brand mark: HANZO logoContent + ui/HanzoMark now render @hanzo/logo
MARK_PATHS (the canonical 7-path shaded H, currentColor) — the flat 5-path
copies are gone; lux/zoo/pars white-label marks untouched.
- AppLauncher: Surfaces row (hanzo brand only, white-label safe) with the
shared shell's hanzo.team tile + billing.hanzo.ai.
- deps: @hanzo/logo ^1.0.13, @hanzo/ui ^8.0.5 (transpiled — ships raw TS).
Upstream unblocks published for the install: @hanzo/ui 8.0.5 (canvas peer
floor >=0.1.0) + @hanzo/ui-shadcn 5.9.1 (framer-motion ^11 || ^12).
- main-green drive-bys: proxy-allow duplicate 'dns' head removed;
billing-accounts test repointed to the shipped /v1/billing form;
shell test follows the Social→Publish display rename.
tsc 0 errors; vitest 2757/2757; next build + build:embed green.
Extends the existing models product with two tabs rather than adding a second
catalog. The Catalog tab gains a vision capability badge derived from the
catalog's own features, both sides of the per-Mtok price, and the published
benchmark headline. Leaderboard ranks the enso-bench prior corpus by any
benchmark it covers. Blend lets an org choose the models its router runs over
and shows the Enso flash, blend and ultra tiers re-forming as that set changes.
Benchmark scores are a checked-in fixture regenerated from hanzoai/enso-bench by
scripts/sync-benchmarks.mjs and imported at build time, because the corpus is a
versioned artefact rather than live state: it changes when a bench run lands, not
per request, so an endpoint would buy nothing and cost a loading state, a failure
mode and a fabrication risk on every page view. Every score keeps its source, our
own harness is badged, a model with no published score renders an em-dash rather
than a zero, and a model unscored on the selected benchmark is omitted from the
ranking rather than ranked last at zero.
The blend rules are a port of the reference semantics in enso-bench arms.py
resolve_blend, so the console means exactly what the router means by enabled.
Alongside the hand-written semantics tests there is a parity suite that executes
arms.py and diffs its real output; it caught a genuine divergence, since Python's
stable sort preserves catalog order for equally priced models and an id tie-break
in the port silently reordered them. That suite skips cleanly when the enso-bench
checkout is absent so CI never fails on a missing sibling repo.
Blend persistence rides the org's existing OrgSettings row rather than inventing
an endpoint. The gateway does not yet carry the three model columns, named in a
TODO on the client, so the write is attempted for real and then re-read to
confirm it survived; when it did not the board says so plainly instead of
confirming a write the backend discarded.
Vendor logos reuse the existing self-contained ProviderLogo marks with a monogram
fallback, with no external requests. The blend row's vendor label now resolves
identity-first through the same resolver the avatar uses, so a gateway-served
model reads as its true vendor rather than showing Zhipu or Moonshot artwork
beside the word Zen.
Verified with typecheck, the unit suite, both builds including the go:embed gate,
and a Playwright spec that drives all three surfaces in a browser.
The GitOps module called /v1/gitops, which cloud never binds — the CD surface
was dead in the console. Repoint to the served /v1/deploy projection, remap the
DTO fields, admit deploy through the BFF proxy, and render the fleet on the
@hanzo/canvas Railway board with a node drawer.
Turn the console CD product into a mobile-first deployment map wired to the
native cloud CD projection (cd.hanzo.ai's surface).
- FIX the #1 blocker: repoint the client from the never-bound /v1/gitops to
cloud's /v1/deploy, and REMAP the real clients/deploy DTOs into the console
view-models (repository/version->image, runningVersion->liveTag,
healthMessage->message, parentRefs[].ref->ownerRefs, object manifests->JSON,
logs blob->lines). Add the deploy head to the proxy allow-list.
- RENDER the fleet as a @hanzo/canvas ProjectCanvas map: foldFleet folds each
App CR into a service node (CD health status, git/image source, capability,
deploy time), a deterministic grid layout (no invented edges), env switcher,
search, and click-to-filter KPI tiles.
- ENRICH nodes best-effort in parallel: git repo+branch (GitApi.repos) and CI
build time (BuildsApi); missing enrichment never drops a node.
- DRILL-IN drawer (ServiceDetailChar tabs): Resources = the owned-resource
topology via treeToGraph, Deploys = the CI build timeline, Logs = live pod
logs, Source = git repo/branch/commit + image; confirm-gated Sync + Rollback
(rollback offers only real clean-semver git releases).
- MOBILE-first: full-screen drawer, touch pan/zoom, no horizontal body scroll,
nav collapses to the hamburger.
- Mount the shared @hanzo/canvas primitive (the base @hanzo/gitops wraps);
delete the dead parts.tsx + ui-contract.ts mount-seam.
- Tests: 52 (client DTO mapping + fold/grid/release-targets) + a responsive
Playwright e2e (desktop 1440 + mobile 390, screenshots, no-overflow +
nav-collapse asserts).
Every model-selection surface now uses ONE selector fed from the live gateway
catalog — the hanzo.chat family-grouped picker (Enso, Zen, Anthropic, OpenAI
first, then alphabetical; family headers; premium chip; search; monochrome).
Console runs @hanzo/gui (Tamagui), NOT @hanzo/ui-shadcn, so per the one-way-per-
repo rule this is a faithful Tamagui twin of @hanzo/ui/models ModelSelector with
the SAME contract ({models,value,onChange,size,chatOnly}) and SAME family taxonomy
(~/lib/api/families groupModelsByFamily, mirroring @hanzo/ui/models). Drop-in swap
when the console migrates to the shadcn design system.
- ModelSelector.tsx: the ONE selector (family sections, premium PRO chip, search
over the catalog, keyboard nav, monochrome marks, honest free-text fallback).
- useModelCatalog.ts: the ONE shared catalog-entries hook (fetchCatalog via the
authed /ai proxy). Playground useModels now derives its ModelOption view from it
(one fetch, exposes raw entries) — DRY.
- families.ts: add Enso as a first-party house family (own family, Hanzo mark);
reorder to the contract (Enso, Zen, Anthropic, OpenAI, then alphabetical) as the
ONE ordering shared by the selector AND the Models browser; add the contract-named
groupModelsByFamily(catalog,{chatOnly}); groupByFamily stays its chat-only default.
- brand.ts / ProviderLogo.tsx: Enso resolves to the house brand; ProviderLogo gains
a `mono` monochrome mode for the selector.
- Rewire: Composer + Evals (model under test + judge) onto ModelSelector; delete the
two old pickers (products/ModelPicker, playground/ModelPicker) and the superseded
provider-cascade grouping (playground/providers).
- Kill sunset zen4 literals in RouterPolicyEditor placeholders (catalog is truth).
Not unified (noted for later convergence): the modality playgrounds (image/video/
audio/embeddings) keep ModelSelect (a different domain — picking a non-chat model),
and agent-builder keeps its injected-loader ComboBox (a portable, decoupled builder).
Verify: tsc --noEmit clean for every touched file (54 pre-existing env-noise errors
from uninstalled @hanzo/canvas|capture|usage, down from 59 baseline — @hanzo/ui not
installed as expected); vitest families+brand+default-model+catalog 65/65 pass.
Ships to console.hanzo.ai only via the next hanzoai/cloud release embedding
console@main (CONSOLE_REF=main) — landing on console main does NOT deploy by itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pages and Workers are wired; R2/KV/D1 render as labelled Phase-2 tabs that
explain themselves instead of exposing controls that would 501.
- src/lib/api/cloudflare.ts: typed client over the asset plane. Relays
Cloudflare API v4 `result` verbatim, so the normalizers map the real CF
shapes (created_on, latest_stage, script id = script name) and tolerate the
`{success:true}` object the backend returns for an empty result. Name and
id validators mirror the backend's nameRE/idRE so a bad field is an inline
error, not a 400.
- CloudflareModule: Pages (projects, deployments, custom domains) and Workers
(scripts, workers.dev subdomain, zone routes). 503 -> connect-account
affordance linking the existing integrations flow rather than a second
connect surface; 403 explains the org-admin write gate; 501 -> Phase 2.
- BackendState: classify 501 as not-implemented, distinct from 404. The route
exists and answers honestly; say that rather than "not available here".
- ConfirmDelete: lifted out of DnsModule so both modules share one destructive
confirm.
Registered under Network beside DNS and Domains.
The Promo tab's Current-promo + Edit-promo panels (and the Caps tab's
Target-organization panel) are stacked in a vertical YStack but used Panel's
default grow=true (flex:1). Per Panel's own contract, flex-grown panels in a
column with no fixed height collapse onto each other — the Current-promo facts
overlapped the Edit-promo slider, making the flagship admin surface read as
broken. Pass grow={false} on the three column-stacked panels so each sizes to
its content and stacks cleanly (the documented fix).
Also folds in the surface's save/validation Note improvement: a success
confirmation renders a green check, reserving the red warning triangle for
errors. Verified locally (mocked global-admin render, before/after) — both tabs
stack cleanly desktop + mobile; tsc clean; admin/promo/budgets vitest 50/50.
The social product face is renamed to Publish everywhere it's user-visible — the
registry label + description, the SocialModule PageHeader title, and the single-
product shell wordmark (social.hanzo.ai header). The description now leads with the
user's framing: 'Queue and publish your content everywhere.'
Kept UNCHANGED (one seam, one name for the value — only the label moved): the
internal id 'social', the /v1/social + /v1/marketing cloud seam, config.socialOnly,
shell==='social', and the social.hanzo.ai host mapping. Renaming those would break
the folded cloud binding + the host→mode resolution.
tsc --noEmit clean (0 errors).
Promote DnsModule to the full zones/records CRUD dashboard (typed /v1/dns
client in lib/api/dns.ts, Cloudflare Proxied toggle, DNSSEC/TTL/priority),
admit the org-scoped 'dns' cloud head through the console BFF, and add the
'dns' product shell so dns.hanzo.ai boots straight into the dashboard. Add the
Domains module (register/renew names via cloud clients/domain → name.com).
Integrates console feat/dns-crud-dashboard (c6f7bb54a) + feat/dns-proxy-head
onto main; the 'dns' proxy head is added exactly once. Domains work committed
from the working tree.
tsc --noEmit clean; vitest green (dns 24, proxy-allow 26, shell 11, config 41).
Assisted-by: neo:claude-opus-4-8
Two-tab admin module (admin: true, hidden from customers, gated by
useIsSuperAdmin + the server getAdminGate):
- Promo: view + upsert the single platform plan promo (percentOff, UTC
start/end window, applicable paid plans, active) over GET/PUT /v1/admin/promos.
- Caps: pick a target org, list its usage caps (threshold, hard-cap vs alert,
softPct, rate limit, derived periodSpentCents/over/warn/resets), and
create/edit/delete over GET/POST/PATCH/DELETE /v1/admin/spend-caps?org=<slug>.
Wiring: promos + spend-caps added to ADMIN_AGGREGATE_HEADS (admin-aggregate.ts)
and ADMIN_V1_HEADS (next.config.mjs); PATCH + DELETE handlers added to the
global-admin-gated /admin/aggregate proxy (PUT already present). client.ts gains
origin{Put,Patch,Delete} (request extended to PUT/PATCH/DELETE). Caps reuse the
tenant SpendAlert primitive + budgets-logic verbatim — one caps model, no fork.
tsc clean; +45 unit tests (promo-logic, admin-promos, admin-spend-caps,
admin-aggregate heads); next build green.
Add the 'dns' head so the DNS control plane (hanzoai/dns at dns.hanzo.ai) is
reachable through the same-origin /v1 user-bearer proxy: the DnsModule can list/
manage authoritative + Cloudflare zones over /v1/dns/*, org-scoped by the JWT
owner claim server-side.
Detail rail now shows the project's REAL resources — Live URL, a Resources band
(Compute honest '—' edge-served, Storage from the live deployment bytes/files,
Domains count + bound-host list, Deployments count+status) — from /v1/projects/:slug
deployments + domains. Honest zeros; no faked project-scoped compute. Ships to
console.hanzo.ai on the next cloud rebuild (go:embed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the go:embed console.hanzo.ai (served by the hanzoai/cloud binary, no Next BFF),
the casibase COOKIE-session endpoints are served UNPREFIXED — /v1/get-account,
/v1/signin, /v1/signout, /v1/update-preferences. account.ts called them under
/v1/iam/* (the OIDC/bearer gate → 401), so a valid cloud_session_id resolved to
null and AuthGate rendered SignIn despite being logged in. Drop the 'iam/' prefix
on the four session calls; iam/keys + iam/onboard stay (cloud serves those at /v1/iam/*).
Ships to console.hanzo.ai via a hanzoai/cloud rebuild embedding console@main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Langfuse retired (observability on SigNoz/o11y span plane). Removes the two
user-facing 'Langfuse' strings from the AI Metrics card. Applies the net
change from feat/ai-overview-page + chore/fork-hygiene-notice (identical
2-line debrand) directly, without dragging their stale 69-commit history.
feat/analytics-instrumentation: page/event instrumentation across sign-in,
onboarding, api-keys, plans, projects, create-app; dep rename
@hanzo/analytics -> @hanzo/capture. SignInForm conflict resolved keeping
main's live IAM provider list + Apple/GitLab/Wallet social specs (dropped
the branch's stale unused QrCode import). typecheck clean.
The release workflow tagged the image :v<package.json version> on every push to
main, so a push that did not bump package.json re-published different bytes under
the same :v tag — anything pinned to it silently drifted (a deploy got an image
other than the one the tag was cut for).
Each build now pushes an immutable, content-addressable sha-<short-git-sha> tag
(the tag deploys should pin), then retags that proven image to the next free
v<X.Y.Z> = max(highest git tag, highest pushed container tag) + 1 and pushes the
git tag as a receipt. Order is build -> push image -> tag, so a failed build
leaves no tag and no version number is ever reused or overwritten. The workflow
owns the v* tags (the v* push trigger and the package.json tag-compute are gone);
the lane is serialized and never cancels mid-flight.
Ships two customer-facing features on the home + shell:
- Observability front-and-center on the overview (reuses ProductObservability).
- Native, CSP-safe first-run guided tour (Appcues alternative; zero external scripts).
Plus the comprehensive billing/usage/o11y E2E smoke (e2e/billing-usage-o11y.spec.ts).
tsc clean; tour vitest 10/10; the go:embed build:embed gate runs in CI.
A fully NATIVE product tour (Appcues-style) with ZERO external scripts and zero new
deps, so it works inside the go:embed static console and under any CSP. GuidedTour
spotlights data-tour anchors with a box-shadow cutout + a floating tooltip (centers
gracefully when an anchor is absent/hidden); FirstRunTour shows it ONCE per account
on the home, after onboarding (defers via the onboarding local guard so they never
overlap). Pure, versioned, owner-keyed seen-guard mirrors lib/onboarding/guard.
Files: src/lib/tour/{steps.ts,steps.test.ts}, src/components/tour/{GuidedTour,FirstRunTour}.tsx,
mounted in (dashboard)/layout.tsx, data-tour="nav" anchor on the sidebar.
tsc clean; vitest 10/10 (tour logic).
The home now surfaces the platform's live LLM signals — RED metrics, recent
logs, recent traces — directly on the overview, the way Langfuse put its metrics
dashboard up top. Reuses the ONE shared ProductObservability panel over the 'ai'
inference service (honest-empty until o11y emits; deep-links to /o11y). Adds
data-tour anchors (api-key, metrics) for the first-run tour. Additive, no new
deps, tsc clean.
build-image.yml publishes v<package.json version> on a main push, so the merge
that fixed the proxy republished v8.4.138 — the tag the fleet already ran. The
image changed underneath a name that did not, which is not a release.
8.4.139 gives the fix a name to be rolled to. crs/console.yaml moves to it once
CI publishes.
The fix: /paas/<x> forwarded to /v1/<x>, so every call 404'd (/paas/apps ->
/v1/apps). It aimed there because that is where the standalone Node platform
served apps; the plane moved into cloud under /v1/paas and the path never
followed.
- Land the comprehensive billing/invoices/usage/o11y render smoke
(billing-usage-o11y.spec.ts: every billing sub-page, invoice view/download/
statement/reload, settings, usage/metrics/AI-metrics, the full o11y set, and a
dead-card audit) — the E2E agent authored it but never committed before the
session limit.
- console.spec + live-billing-admin: assert the TRUE "no data tunnel" invariant
(an unauthenticated request never gets a 2xx carrying backend JSON; a SPA-HTML
fallback and a >=401 gate both pass) instead of brittle exact status codes on
renamed/pruned paths (superbase->base, the go:embed-pruned /admin/aggregate).
theme-color #0a0a0a -> #000000 (live value).
- Shared fixture-server gate (_fixture.ts): the localhost:4000 render specs
(ai-economics/budgets/gpus/provider-billing(A)/entitlement-sidebar/
interactive-training/blank-audit) skip cleanly when that server is unreachable
instead of ECONNREFUSED-failing against prod.
- probe-o11y skips without HANZO_PASSWORD instead of hard-throwing.
- playwright retries:2 in CI to absorb Tamagui/RNW SPA-hydration render flakiness
(a real regression fails every attempt, so nothing is masked).
Full live run vs console.hanzo.ai: 15 passed / 299 skipped-cleanly / 0 failed.
Prod posture verified live: /v1/admin/* -> 403 JSON (fail-closed), no data tunnels
(superbase/keys/aggregate all SPA-HTML, never backend JSON).
/paas/<x> built /v1/<x>, so every call 404'd: /paas/apps -> /v1/apps. It aimed
there because that is where the standalone Node platform served apps; the control
plane moved into cloud under /v1/paas and the path never followed.
Now /paas/<x> -> /v1/paas/<x>, name-preserving on both sides. apps is the proxy's
only consumer, so nothing else moves.
/paas/<x> built ${PLATFORM_URL}/v1/<x>, so every call landed on a path that does
not exist: /paas/apps -> /v1/apps -> 404. The board rendered nothing and the token
was never the problem (PAAS_SERVICE_TOKEN is set, 64 bytes).
It aimed at /v1/<x> because that IS where the standalone Node platform served
apps. The control plane moved into cloud under /v1/paas and this path did not
follow — the console kept asking the old shape of a service that no longer has it.
Now /paas/<x> -> /v1/paas/<x>: the route is the PaaS plane, so it forwards to the
PaaS plane, name-preserving on both sides. `apps` is the proxy's only consumer
(platform.ts url()), so nothing else moves.
NOT verified end-to-end: /v1/paas/* is SuperAdmin-gated, so the rendered board
needs a superadmin session to confirm. What is verified: the upstream path exists
(/v1/paas/apps answers, 500 "SuperAdmin required" — reached and refused, not 404),
cloud's RBAC to read App CRs is fixed, and /v1/paas/health is 200.
Sidebar + product-nav overhaul for console.hanzo.ai (embedded in the cloud release):
- nav-accordion: SINGLE-OPEN → EXPAND-ALL-BY-DEFAULT. Every category renders expanded;
an optional per-section chevron collapses one INDEPENDENTLY, persisted per-user
(navCategoriesOpen), respected on every render; filtering force-opens. Tests rewritten.
- Category headers un-indented — flush-left with Overview/Docs (count + collapse chevron
moved to the right), so the hierarchy reads clean.
- Whole-sidebar collapse to an icon RAIL (topbar toggle, persisted) with a HOVER flyout
overlay that doesn't push content (classic rail+flyout); mobile keeps the left drawer.
- Drill-in / drill-back: clicking a product with sub-pages DRILLS the sidebar into its
sub-nav (Overview · specifics · Settings/Status/Logs/Metrics) with a Back affordance;
a single-page product navigates directly (replaces the inline sub-nav).
- Add-product panel: the broken "Enable" gate → pin/unpin (+ / −) to the sidebar; "In
use" discovery from the real usage ledger; keeps "only pay for what you use".
- Bottom-left dedupe: ONE OrgSwitcher (org avatar + name; "All organizations" folded into
its dropdown) — removed the redundant app-grid button. SidebarWallet = real live balance.
- Chat widget dockable as a PERMANENT right column (floating <-> docked, persisted); on
phones it stays the floating bubble/sheet.
- Graceful 402 add-credits/top-up across the shared error primitives (States/BackendState).
- Mobile: overlay drawer + tap-scrim, >=44px targets, docked chat lg-only, no horizontal scroll.
Drive-by: align the stale projects.test.ts with the shipped /v1/iam routing (commit
1e17f0383 moved the code but not its test) — restores main to green.
Gate: tsc --noEmit 0 errors; vitest 2565/2565.
Decomplect hanzoai/cloud's build: the console SPA static export (npm run
build:embed -> out/) is now a versioned immutable image (console-embed, /dist)
built by console's OWN CI, not re-run inside every cloud release. cloud will
FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console + COPY --from,
turning the ~15-min cache-busted npm+Next long pole into a registry pull.
- Dockerfile.embed: node build -> FROM scratch with /dist (fail-hard on a
missing/placeholder bundle, same invariant cloud's console stage enforced;
bakes the same public console.hanzo.ai analytics id).
- hanzo.yml: images: console-embed (hanzoai/ci builds + auto-mirrors to
registry.hanzo.ai). Next.js server image stays in build-image.yml.
- cicd.yml: canonical hanzoai/ci caller.
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Projects + the member roster called the /org/iam BFF proxy, which the one-binary
(static-export) console cannot run — so on console.hanzo.ai they returned the SPA
shell and the Platform page showed "Request failed (HTTP 200)". Route them through
the main client at /v1/iam (the cloud IAM edge) instead, so ONE path serves both
the one-binary and split topologies, with CSRF + X-Org-Id + retry + envelope
unwrap for free. IAM's {status,msg,data,data2} IS our ApiResponse — no second
client (iamList/iamOne/iamMutate on client.ts).
tsc --noEmit clean.
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
A responsive card grid (like the reference) so a user finds the right door without
leaving the console: Product Support (support@hanzo.ai), Contact Sales
(sales@hanzo.ai), X (@hanzoai), Discord (discord.gg/hanzo), LinkedIn (company/hanzoai),
GitHub (hanzoai), Hugging Face (hanzoai), More to Come. hanzo.ai is the hub; the AI
chat widget answers first, this is the human/channel fallback. Registered as the
`contact` product (Settings category). Pure presentational, mobile-stacked.
Per the privilege-separation: z@ via console resolves to hanzo/z (non-admin), so
the credit gate refuses it. The funding spec now signs in through the admin.hanzo.ai
surface (admin-guard → owner==admin) and POSTs the credit + balance read there — the
only identity the gate admits. Robust login selectors for the hanzo.id portal form.
Live Playwright E2E: z@ signs in, credits the maxpower org via
/v1/admin/customers/:org/credit, and asserts the balance moves by exactly the
grant; a second spec verifies the funded member (davelorenzini) reaches /platform
with no dead "Could not load". Secrets come from env (HANZO_PASSWORD/DAVE_PASSWORD)
— never hardcoded; the credentialed specs skip without them, so CI stays green.
Surfaced a defect: unauthenticated credit returns 500, not 403 (core.Guard's
*zip.HTTPError 403 is re-wrapped as a generic api-error 500) — which is why the
console renders "Could not load" instead of an auth state. The fail-closed test
asserts rejection (no money moves) and flags the code for a follow-up fix.
The settings-as-Base admin pattern: a super-admin "Routing" editor on
admin.hanzo.ai that edits the platform + per-org auto-routing policy as DATA
(Base/SQLite OrgSettings rows), never env or a session-gated code toggle. This
is where auto-routing (enso) becomes a real admin toggle.
- OrgSettingsApi over /v1/{get-org-settings-list,get-org-settings,
update-org-settings,delete-org-settings} (super-admin gated upstream), on the
SAME originGet/originPost /ai bearer transport the router policy uses (no new
transport). Every write is read-modify-write, so sibling routing-policy fields
(routerPrefer, costCeiling, defaultSessionRouting, trainingContribution) are
never clobbered by the backend's full-row replace; revert to inherit deletes
the row only when it holds nothing else, else it clears just the field.
- RoutingModule (admin): three-state control (inherit / enabled / disabled) for
the global "*" default + per-org overrides, inline-editable, add-override for
an org not yet listed, honest empty + 403 states (OperatorAccessRequired). The
Hanzo brand seeds the org-first activation row — set org hanzo -> Enabled.
- Route the four heads through next.config AI_V1_HEADS + the /ai proxy ALLOWED set.
- 13 unit tests: state mapping, planSave field preservation, revert = delete.
GPUs — SEE your connected machines. The BYO connect fleet (`hanzo gpu connect`
boxes: home lab dbc/evo/spark) registers with a per-box heartbeat that ONLY
`GET /v1/fleet/workers` carries (`/v1/machines` folds them in without it,
`/v1/gpus` expands per-accelerator without it). New `FleetApi` (lib/api/fleet.ts)
reads it over the same-origin `/v1` bearer BFF (`fleet` allow-listed in
proxy-allow.ts; direct on the go:embed console), and a reused `ConnectedMachines`
section on the customer GPUs Overview + GPUs tab lists each box: name, accelerator
(arch), memory, online/offline (server-derived at a 90s heartbeat TTL), last
heartbeat, and a "Serving" badge when it runs hanzo-engine. Cloud GPU VMs
(`/v1/machines`, non-BYO) stay a separate list so a box is shown ONCE (DRY).
Honest states throughout; nothing fabricated.
Wallet — the sidebar chip shows the org balance (`/v1/billing/balance`, unchanged)
and "Top up" now LINKS to the brand's hosted payment page in a new tab. New
`config.payUrl` derives `pay.<brand>` from the brand billing host (white-label-safe:
a Lux console links to pay.lux.cloud, never pay.hanzo.ai). Display + link only — the
console hosts no card form and mints no credit.
Mobile — the section reuses the shared DataTable (scrolls inside its own overflow-x
box, never the page body) + flexWrap stat cards, the codebase's one responsive
mechanism. Verified live at 390x844 and 768x1024 (no horizontal body scroll).
Tests: +8 fleet (normalizer/helpers/route), +1 config payUrl (white-label), +3
responsive e2e (desktop render + phone/tablet no-overflow). Full suite 2544 green;
tsc clean; next build + build:embed (go:embed gate) green.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Extend the existing `router` product (PR #160's org-user surface) so it covers ALL
org AI usage in addition to training + routing — without duplicating anything:
- New "Usage" tab in RouterModule (Overview · Usage · Policy) that renders the org's
AI usage by REUSING the existing plumbing: native Hanzo usage (CloudUsageApi.overview
→ GET /v1/get-cloud-usages) beside imported connected-provider usage, via the same
@hanzo/usage <UsagePanel>/<ConnectedUsage> the AI Metrics module already uses.
- Factor the shared usage-board body (fetch + honest async state for both planes) out
of AiUsageModule into ONE component, src/components/products/usage/AiUsagePanels.tsx;
AiUsageModule is now a thin adapter over it. One implementation, never a second copy.
- Relabel the `router` registry entry (id + route unchanged, still non-admin, category
AI): label "AI Usage & Training", description mentions usage + training + routing; add
a "Usage" subpage beside "Policy" in the level-2 sub-nav.
- Training status stays sourced from GET /v1/router/stats (Overview: retrain-gate line +
quality proxy) and the opt-in training-contribution toggle stays in EXACTLY ONE place
(RouterOverview) — not added to AiUsageModule or the shared body.
No admin/platform-operator surfaces touched. tsc clean; vitest 2535/2535; next build ✓;
build:embed ✓ (static export ready, 31 handlers restored).
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
Co-authored-by: hanzo-dev <dev@hanzo.ai>
/v1/plans now carries promoPercent/promoUntil (commerce v1.48.6). The plan card
shows the effective (post-promo) price big, the list price struck through, and a
'50% off · limited time' badge — the discount is derived from the ONE plan source
via the pure, reusable lib/billing/promo (promoActive/effectiveMonthly/promoLabel),
no second discount source. Degrades cleanly to the list price when no promo is live.
iam2 serves ONE token endpoint — the RFC/discovery /oauth/token. No backwards-compat
access_token spelling; fix the client to the standard, not the backend to the client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`useSearchParams()` is typed ReadonlyURLSearchParams | null, so the five raw
searchParams.get()/.toString() reads were `tsc --noEmit` errors (TS18047). CI only
runs `next build` (which tolerated them), so the standalone typecheck had drifted
red unnoticed. Fall back to empty params in one place; `npm run typecheck` is clean.
22ea663bf3 ("signup mints NO credit") deleted lib/server/billing-grant.ts and its
test but left app/auth/signup/route.ts still importing and awaiting grantWelcomeCredit,
so `next build` could not resolve '~/lib/server/billing-grant' and the v8.4.136 image
never shipped — the last two pushes to main (signup-no-credit and the #160 merge)
both failed to compile.
This finishes that commit's stated intent: the route no longer imports or calls the
grant, so a new account starts at $0 (credit comes only from an admin grant or the
user adding funds), and the header doc drops the now-false "$5 welcome grant" line.
Removed the server-side signup auto-grant: app/auth/signup/route.ts no longer
calls grantWelcomeCredit (→ commerce /v1/billing/grant-starter). Deleted the now-
dead lib/server/billing-grant.ts helper + its test (no residual). Together with
the session-bootstrap claimWelcomeGrantOnce removal (bd6e583cb), the console no
longer auto-grants any credit on signup or load. A new account starts at $0;
credit comes only from an admin grant (admin.hanzo.ai) or the user adding funds.
Upgrade the `router` product from the single policy-editor route into a two-tab
Router dashboard: Overview (routing observability) + Policy (the reused λ/µ
editor). One editor, one place — no duplication.
Overview reads GET /v1/router/stats (org-scoped, RequirePrincipal) and renders:
(a) cost saved as a blended $/MTok PROXY — saved_pct + routed vs counterfactual
index + cumulative saved, honest "—" when priced_events==0; (b) quality proxy —
reward_rate + coverage, engine_share, avg_confidence, shadow_agreement only when
non-null; (c) per-task routed-model distribution (Donut) + by-model Donut +
throughput LineChart; (e) an opt-in training-contribution toggle wired to
GET/POST /v1/{get,update}-training-contribution (feature vectors only, optimistic
+ honest revert); (f) the last-retrain gate verdict line.
- New pure, node-tested logic (components/products/router/logic.ts, +15 tests):
normalizeStats (partial/garbage → honest empty, cost stays null not $0),
formatters (em-dash on absent), distributions by share, throughput UTC labels,
retrainLine, range→hours. Reuses ui/Charts + ui/Metric + EmptyState/
BackendStateCard — no chart dep, honest states throughout.
- Transport mirrors get-router-policy exactly: three heads added to
next.config.mjs AI_V1_HEADS + app/ai/[...path] ALLOWED (v1/router/stats,
v1/{get,update}-training-contribution) — the /ai user-bearer proxy; no new
route handlers. go:embed hits cloud natively (honest BackendStateCard until the
ai router-stats wave ships).
- Also completes the half-applied v8.4.137 rename that left origin/main
non-building: RouterModule.tsx (the editor) → RouterPolicyEditor.tsx with its
broken ~/lib/api/router-policy import + InferenceRouterModule export fixed; the
registry entry repointed (id inference-router → router). No package.json bump.
tsc clean; vitest 2540/2540 (213 files, +15); next build ✓; build:embed ✓.
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
New AI-category product 'inference-router': org admins edit their own task →
model-pool prefer table + per-1k cost ceiling over the new hanzoai/ai
/v1/get-router-policy + /v1/update-router-policy (org-admin gated, self-scoped,
org > '*' > conf fold). v1-first transport: originGet/originPost + the two heads
added to AI_V1_HEADS dispatch and the /ai proxy ALLOWED set — no new route
handlers. tsc clean, vitest 2525/2525, next build + build:embed green.
Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
Two fixes:
1) BOOT HANG (console.hanzo.ai splash): the session bootstrap awaited
AccountApi.session() (→ /v1/iam/get-account) with NO timeout, so a degraded
backend — the beego IAM proxy hop, a dead pruned route blocking 12s — left the
splash pending forever (diagnosed live: get-account PENDING >25s, body empty).
New reusable withTimeout() primitive caps the boot resolve at 8s → on timeout
the visitor is anonymous and the sign-in card renders; a later reload/refresh
resolves the real session. An app must never hand the browser to one request.
(Root cause is the beego get-account proxy — fixed for real by the iam2 flip.)
2) AUTO-CREDIT: removed claimWelcomeGrantOnce() from the session bootstrap — the
console auto-claimed the $5 welcome trial credit on every authenticated load.
No more automatic credit; admin grants at admin.hanzo.ai only.
The 19 local tsc errors are a node_modules gap (@hanzo/capture ^0.1.0 not npm-installed
locally); CI resolves it. session.tsx + with-timeout.ts are clean.
New admin.hanzo.ai module (`admin: true`, category AI) answering the KEY
question "how many requests hit each model" plus unit economics and the
eval→training flywheel. Composes the existing admin reads — never forks them:
- Model mix: requests / share% / tokens / cost per (provider, model), folded
from /v1/admin/usage/funding over a 24h/7d/30d window (share donut + table +
totals row).
- Profitability: upstream cost vs revenue vs gross margin + runway
(/v1/admin/finance) and per-provider credit (/v1/admin/providers/credit).
- Training data: the HONEST collection card — the metering ledger (datastore)
holds no prompt/completion content and nothing harvests traffic; the only
training data is the user-curated eval dataset registry (live counts).
- Evals: recent LLM-as-judge runs (dataset, evaluator, score, when).
- Router loop: how eval scores fold into the enso router (offline ridge +
online LinUCB), with the honest "per-request reward not yet persisted".
Pure rollups in src/lib/api/ai-economics.ts (foldModelMix / topModelShares /
datasetStats / marginTone …) with 19 vitest cases; one route-mocked Playwright
spec (fable-5 75% mix, 62% margin, honest training card, fail-closed gate).
v8.4.135
The console ships into hanzoai/cloud as a Next.js STATIC EXPORT: generateMetadata
runs at build time with the default host, so the exported <title> is baked to
"Hanzo Cloud Console" for every host. The cloud serving layer rewrites it per
Host on first paint, but Next re-applies the baked metadata title on hydration,
reverting a Lux/Zoo tab to "Hanzo Cloud Console" — a white-label violation
(the visible shell was already client-branded; only the tab title leaked).
BrandTitle is a client net (mirrors ChunkGuard) that sets document.title from
window.location via the existing `branding.name` and re-affirms it through a head
MutationObserver, defeating the baked-metadata re-application. On the dynamic
standalone app the SSR title is already host-correct, so it is a no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the Interactive tab to Fine-tuning: a live LoRA client you create on a
base model, drive with forward_backward + optim_step (plotting the real loss
curve), sample from, and export a PEFT adapter.
TrainingApi hits the clean /v1-first `/v1/training/*` (next.config dispatches
the `training` head to the keyless /ai bearer proxy; the per-client
id/forward_backward/optim_step/sample/save_weights sub-paths are allow-listed,
with a DELETE handler). Engine 400/404/409 plain-text bodies surface verbatim.
Tolerant normalizers, vitest + a mocked Playwright e2e. v8.4.134.
Every client-facing same-origin API path is now /v1/<head>/… — one version,
no /<svc>/vN/ prefix, no nested /v1/<x>/vN/, no /api/. Supersedes the v8.4.16
/billing/v1 namespacing; completes the v8.4.120 /v1-rooted contract.
- Move 11 proxy handlers app/<svc>/… -> app/v1/<svc>/[...path] (a filesystem
route beats the /v1/[...path] cloud BFF; handlers re-root the upstream at v1/):
billing, commerce, ai-accounts (+settings/usage/routing-defaults), economy,
nodes, trading, superbase, vm. Auth/scoping/CSRF/allow-lists UNCHANGED —
only the path moved. UI tabs (/billing/*, /ai-accounts/*) still render.
- Remove the /v1/billing->/billing/v1 and /v1/commerce->/commerce/v1 rewrites.
- AI heads: playground images/videos + ai-connections build clean /v1/*;
next.config dispatches to /ai WITHOUT a nested version; app/ai re-roots at v1/.
New `ai` head so /v1/ai/connections dispatches. Fixes image/video/connections
on the go:embed console.
- Drop nested /v1/websearch/v1/scrape -> /v1/websearch/scrape; repoint apm
stale /api/v1 doc comments to the /v1/o11y the client actually calls.
- Left external (untouched): Gatus /api/v1, Cloudflare Turnstile /turnstile/v0,
Slack OAuth /oauth/v2.
tsc clean; vitest 2445/2445 (206 files); next build (route table shows every
/v1/<svc>/[...path] distinct) + build:embed green.
git grep -oE '/[a-z-]+/v[0-9]/' = external hosts only.
Billing Center gains an Accounts tab: which account pays, and in what order.
Attach a billing account to the ORGANIZATION or to ONE PROJECT, see the ordered
chain, reorder it, detach it.
The chain is READ from commerce's own resolver, never recomputed here. Commerce
resolves the payer at charge time and is the one source of truth for who pays, so
the page writes a PRIORITY and re-reads the chain — it never predicts the order.
A local re-sort would be a second, divergent answer to the one question that must
have exactly one, and would start lying the moment commerce's ordering rule moves.
Attach and reorder are the SAME call: a binding's row id is deterministic in
(holderKind, holderId, accountId), so re-asserting a pair at a new priority
updates that one row. Reordering is not a second verb.
Priorities are anchored on the anchor's fixed 0, so a link promoted above the
derived subject gets a negative priority — how an explicit binding preempts the
anchor. An attach always lands at the END, never silently taking over today's
payer.
The holder is derived, never asserted. Whose chain an account attaches to is a
payer decision, so the browser names a holder KIND and the /billing proxy derives
holderId from the session — the same pin scopedBillingSearch already applies to
the billing subject, extended to the one write body that names a holder. A body
with no holderKind is untouched, so a spend-alert (whose scope IS a project name)
passes through unchanged.
The project switcher additionally sends X-Act-As-Project: an intent and an
assertion must not share a name. The intent is a request a validating boundary
checks against the caller's scope set before minting the authoritative
X-Project-Id; the existing X-Project-Id stamp is unchanged and stays advisory
(the gateway strips it, the /v1 /vm /commerce proxies drop it per the RED MED-1
eval-isolation invariant). The intent is consumed by a boundary and never
forwarded to a backend.
Reuses @hanzo/gui + the shared BackendState/PageHeader/EmptyState primitives; no
component forked, no brand literal (white-label safe). Routes are /v1 only.
Tests: 2499 green (208 files), tsc clean, next build compiles. Each guard proven
non-vacuous by neutering it and watching it fail — including one order test that
passed under neuter until its fixture was fixed to be priority-unsorted.
The admin.hanzo.ai launch dashboard: govern access to every hosted service and
approve users, wired to the cloud featuregate control plane + IAM iam#104.
- FeatureGateModule: a Services board (per-service waitlist-mode toggle — remove
the waitlist one service at a time) + a Pending-Users approval queue.
- src/lib/api/admin-featuregate.ts: client over /v1/admin/services* (list, toggle,
onboard) through the global-admin-gated /admin/aggregate proxy (+ normalizer test).
- IamAdminApi.pendingUsers/approveUser/rejectUser: REUSE the IAM approval API
(iam#104) via the existing global-admin /admin/iam proxy — no second approval store.
- Wiring: 'services' added to ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS;
get-pending-users / approve-user / reject-user added to the /admin/iam allow-list.
- Registered as 'launch-control' (admin-only, Security category).
- tsc --noEmit clean; vitest green (client normalizers + allow-list).
The customer GPUs page now makes both paths to add a GPU explicit and equal:
- "Connect GPU" opens a drawer with the ready-to-copy `hanzo login && hanzo gpu
connect` (+ `--serve-engine` to also serve models) and the Desktop toggle note.
- "Deploy GPU" is the existing Visor/DOKS launch flow (relabeled from "Launch GPU").
Machine rows gain a monochrome Source badge distinguishing BYO (provider=byo) from
Cloud accelerators; the empty state offers both actions.
- gpus/ConnectGpuDrawer.tsx: the BYO connect drawer (copy-to-run commands).
- gpus/CustomerGpus.tsx: Connect+Deploy header actions, ProviderBadge, Source column.
- e2e/gpus-connect.spec.ts: mocked-network screenshot proof (BYO GB10 + cloud H100).
- ConnectionsModule: a real, prominent Connections page (AI category) over the
EXISTING AiConnectionsApi — connect OpenAI/Anthropic/Google by API key or OAuth,
disconnect, honest states. Keys sealed to KMS server-side, never in the browser.
- ai-connections.ts: host-aware base (embed -> cloud native /v1; standalone -> the
narrow /ai bearer proxy, mirroring billing/commerce IS_EMBED) so it works on the
go:embed console.hanzo.ai; + usage()/listWithUsage() import methods.
- AiUsageModule: render <UsagePanel> (native) AND <ConnectedUsage> (imported) together
— the cross-provider plane; per-provider isolation, honest empty until connected.
- /ai proxy allow-lists the /v1/ai/connections/:provider/usage sub-path (standalone).
- @hanzo/usage ^0.1.5. typecheck + build:embed green; 2440 tests pass (+5 new).
ONE source of truth: render the social row purely from IAM get-app-login (the
app real provider list). Drop FALLBACK_PROVIDERS — a hardcoded set drifts from
IAM and can show a button IAM cannot honor. Empty list (loading or IAM
unreachable) => no social buttons; email/password stays. signInProvidersOf now
returns a plain array (one type, no null branch).
The landing `overview` living board's PRIMARY source is the super-admin
`/v1/admin/overview` god-view (gated to the reserved `admin` org), which 403s a
non-super-admin — even an org's OWN admin (e.g. `hanzo/z`, Admin). It is meant to
fall back to the tenant usage ledger so it is never blank, but the fallback read
`UsageApi.overview` -> the `/billing/usage` Next BFF proxy, whose route handler is
PRUNED from the go:embed console that serves console.hanzo.ai. In the embed that
fallback failed and surfaced the honest-but-wrong "Access required" card instead of
the org's real spend.
Repoint the fallback at cloud's NATIVE `GET /v1/get-cloud-usages` (via the existing
`CloudUsageApi`) -- the SAME source the AI Metrics board (`AiUsageModule`) already
reads. It is org-scoped server-side and cookie-authed, so a non-super-admin gets a
200 for THEIR org and the board renders real usage; being cloud-native it also works
in the embed. A 403 on the admin aggregate still falls through here silently (the
isSuperAdmin gate already skips the aggregate for tenants).
DRY: the ONE `fromCloudUsage` adapter now accepts the canonical `@hanzo/usage`
`CloudUsageOverview` (what get-cloud-usages returns), reading the console-only
`byStatus` slice as an optional extension -- so BOTH usage sources (the cloud
aggregate and the `/billing` ledger rollup) flow through it unchanged.
tsc clean; vitest 2435/2435; build:embed green.
Shared @hanzo/capture analytics client wired through AnalyticsProvider + AnalyticsBridge; sign-in/signup funnel, api-keys, plans, projects, paas create events. Rebased onto main (post QR-drop, post link-manager); version bumped to v8.4.133 so the main-push image build publishes a fresh semver tag. tsc --noEmit clean; all touched components are client components.
A new Machines tab on the AI Accounts product: every provider account signed into
Claude Code / Codex / the CLI across your machines, grouped by device, with each
account's live usage (session/weekly rate limits, tokens, spend), how it BILLS (a
subscription bills your plan; an api key bills credits), the device's active
sessions, and a per-account / per-device LOG OUT that revokes the account and stops
its running sessions — plus the redundancy route plan (subscriptions first, then the
metered API backstop) across your accounts.
- lib/api/links.ts — the /v1/links client (cloudProxyV1Url; defensive normalizers);
'links' added to CLOUD_HEADS.
- ai-accounts/links-logic.ts — pure labels/tones/formatting + the KPI roll-up.
- ai-accounts/MachinesTab.tsx — the dashboard (honest loading/error/empty states,
@hanzo/gui v5 shorthands, mobile-responsive flexWrap rows).
- AIAccountsModule + registry: the Machines tab + subpage.
Every number is real from /v1/links or an honest '—'; nothing fabricated. tsc clean;
vitest 2435/2435 (+13); build:embed green. Reachability is the post-deploy gate
(same /v1 BFF / go:embed contract as agents).
Connect the Hanzo GitHub App (existing connect flow) -> a connected GitHub card
opens a repositories view listing the org's granted repos with per-repo import +
live sync status (Not imported / Importing / Synced / Conflict) and import-all.
GitHubApi over the same-origin /v1/integrations head.
A new AI product (Mission Control) over the live agent-session plane
(/v1/agents/sessions): swipe one live terminal per agent session, drive it
(pause/resume/stop/message), and roster the run-targets (#48) with which
sessions run where. Server-side org isolation; pure logic unit-tested; the
catch-all renders it. Ships to console.hanzo.ai via the cloud embed of console@main.
A new AI-category product over the live agent-session plane
(/v1/agents/sessions): one swipe card per session — a live terminal (the
event stream tailed over SSE, poll backstop), a status pill, the machine/
repo/agent it runs on, and the plane's control ops (pause/resume/stop/
message). A Devices view rosters the run-targets (#48) unioned with the
hosts live sessions report, showing which sessions run where, with a
link-a-computer form. Org isolation is server-side (bearer owner).
MissionControlApi over the same-origin /v1 (agents head already allow-listed);
pure logic unit-tested (deviceRoster union/no-double-count, eventLine,
mergeEvents, normalizers).
OrgIntegrationsModule: a connected GitHub card opens a repositories view
(GitHubReposView) listing the org's granted repos with per-repo import + live
sync status (Not imported / Importing / Synced / Conflict) and an import-all
action. GitHubApi (listRepos/importRepos) over the same-origin /v1/integrations
head; pure logic + normalizers unit-tested; StatusTag learns synced/conflict/importing.
The QR sign-in card set an hz_session cookie sealed with the console secret that
cloud's /v1 can't read, while console.hanzo.ai authenticates via the casibase
session — so a QR login left the user effectively signed out for data calls, and
the /auth/device BFF 405s on the static embed anyway. Remove the button, the qr
view, and the dead QrSignIn component + iam-device wire + /auth/device route. One
way in: password + social + email signup. v8.4.131.
The embedded login hardcoded its social buttons, which drifted from IAM:
'Continue with GitLab' hinted provider-gitlab, a provider the hanzo-cloud app
does not have — IAM could not auto-advance and stranded the user on the
hanzo.id login page with no GitLab option (the reported 'redirects to hanzo.id
and doesn't have social options' dead-end). GitHub/Google were verified live to
auto-advance clean; GitLab reproduced the strand headless.
ONE source of truth: SignInForm now renders its social row from
get-app-login's real provider list (CORS-open to the console origin, verified),
mapped per provider TYPE (GitHub/Google/GitLab/Apple/Web3) — an unknown type
renders nothing, and a provider IAM can't honor never gets a button. Fallback
while loading / on a failed read = the set proven live (GitHub+Google+Wallet),
never GitLab. Apple (live on the app) now shows. +4 tests pin the normalizer
and the no-GitLab fallback.
Claude-Session: https://claude.ai/code/session_01XptqW83ZLpqyGBENc1wAQz
Extend the affiliate dashboard onto cloud's new /v1/affiliates surface: per-period
share LineChart + per-referral aggregate, referral-link manager with click/signup/
conversion stats, privacy-preserving leaderboard (opt-in handle + your own rank,
never an org identity), and a SuperAdmin set-rate action. tsc clean, 2394 vitest
pass, next build green.
Extend the affiliate dashboard onto the new cloud /v1/affiliates surface:
- Rewards panel: per-period share LineChart + per-direct-referral aggregate
contribution + the profit-share basis (your rate of Hanzo's margin).
- Referral links panel: list links with click/signup/conversion stats, copy,
and create (label + auto-minted code), respecting the per-affiliate cap.
- Leaderboard panel: opt-in handle + your own rank (always visible) + aggregate
only; never another org's identity.
- Admin: a Set-rate action (percent -> bps, capped at 93%) beside approve/payout.
- Wire a best-effort click ping into the ?aff capture (once per code/session).
API: AffiliatesApi.{earnings,links,createLink,setHandle,leaderboard,click} +
marginBps/handle on the overview; AdminAffiliatesApi.setRate. logic.{monthLabel,
percentToBps}. Defensive normalizers throughout.
Tests: normalizer + exact-path transport tests for every new call; monthLabel +
percentToBps units. tsc clean, 2394 vitest pass, next build green.
Adds Continue-with-GitLab and Connect-Wallet buttons alongside GitHub/Google,
handing off to the hanzo.id hosted login via provider_hint. tsc clean; 2382
vitest tests pass.
Add "Continue with GitLab" (signInWith provider-gitlab) and "Connect Wallet"
(signInWith provider-web3) buttons alongside GitHub/Google. Both hand off to the
hanzo.id hosted login via provider_hint like the existing social buttons; the
wallet button lands on the login page where the native multi-chain SIWx flow
runs and returns an authorization code to /auth/callback.
Thin console surface for the native ArgoCD replacement: a GitOps product
(admin: true, Platform category) that reads the services.hanzo.ai operator CRs
through cloud's /v1/gitops/* — the console holds no cluster creds, cloud enforces
SuperAdmin. Applications board (name/version/health/sync + rollback/sync), an
application detail (header, actions, owned-resource inventory, rollout history,
logs), health/sync pills. The heavy topology/diff/log UI is deferred to the
parallel @hanzo/ui/gitops export via a documented MOUNT SEAM (ui-contract.ts) —
the pure treeToGraph adapter already maps into its @hanzo/canvas model. Adds the
gitops head to proxy-allow CLOUD_HEADS. tsc clean, 2353 tests green.
Rename the deploy-plane client to the GitOps namespace the cloud agent owns:
GET /v1/gitops/applications, /{name}/tree, /{name}/resource/{ref}, /{name}/logs,
POST /{name}/rollback, POST /{name}/sync (sync replaces restart). Domain nouns
follow ArgoCD: Application/HealthStatus/SyncStatus. Pure folds + tree adapter
unchanged (feed the interim board and @hanzo/ui/gitops when it lands). 33 tests.
Consumes the cloud-owned /v1/deploys contract (list/tree/resource/logs/rollback)
that reads the services.hanzo.ai operator CRs — the native ArgoCD-free deploy
plane. Pure logic folds CR .status (phase + ready replicas) into ArgoCD-style
health, desired-vs-live image tag into sync, and a CR's owned-resource tree into
the @hanzo/canvas node/edge model. 31 unit tests green.
The go:embed'd console serves the SPA shell (the / route's index.html) for every
path — verified live: GET / and GET /signin return byte-identical HTML. So a direct
/signin load mounts the dashboard tree (AuthGate), not the /signin route. AuthGate saw
no account and called router.replace('/signin'), a no-op at /signin, and spun on the
loader forever (inputs=0, buttons=0). Reaching /signin as a redirect target (from /,
/projects, ...) worked because the URL changed.
Extract the sign-in experience into one <SignIn/> component (tenant form / admin silent
SSO / redirect-to-/ when authed) rendered by BOTH the /signin route and AuthGate: at
/signin AuthGate defers to <SignIn/> instead of a no-op redirect, so /signin resolves to
the form without depending on a navigation. Add an e2e regression that hard-loads /signin
and asserts the form renders.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Org-level Guide module over the real cloud clients/guide surface: an
interactive launch checklist the Business AI can complete for you.
- GuideModule.tsx — launch-progress bar (Complete/Total/Next tiles), a
focused current-step card (why · how-on-Hanzo · done-when + Mark done /
Skip / a primary Do-it-for-me), and the full step list with a state chip,
a blocked/lock hint, and inline per-row actions on non-terminal steps.
- Do-it-for-me streams the agent's plan → draft → action → result → state
events live (SSE via streamDo), aborts on unmount/close, and falls back
to the non-streaming JSON do when the backend can't stream. An error
event stays an error — success is never fabricated; states are loading /
BackendStateCard / empty throughout.
- Consumes the pre-written guide API client (GuideApi + streamDo, defensive
normalizers) + pure view logic (stateLabel/currentStep/clampPercent/…),
both unit-tested (17 tests). Fix: putCurriculum now takes a parsed object
(the transport JSON-encodes once) instead of a pre-serialized string,
which the restPut body would double-encode.
- Wiring: `guide` added to proxy-allow CLOUD_HEADS (the /v1 bearer BFF
forwards /v1/guide/*), one catalog entry + import in registry.tsx
(Apps, routes '' and ':tab'), and `guide` added to ALWAYS_ON_PRODUCTS so
every org sees the foundational onboarding surface (like 'platform').
Ships to console.hanzo.ai via the next hanzoai/cloud release embedding
console@main; build:embed stays green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Reads GET /v1/kb/graph (kb-page/kb-memory/kb-source nodes; parent,
wikilink, provenance edges) and renders a deterministic Fruchterman-
Reingold force graph on a CSP-safe canvas, click-to-inspect. Import
panel POSTs an Obsidian/Notion/Roam/Evernote export to /v1/kb/import.
Both ride the same-origin /v1 user-bearer BFF; 'kb'/'knowledge' heads
allow-listed in proxy-allow. Registered in the product registry under
AI as 'Knowledge'. Pure graph-logic (normalize/layout/hitTest) is unit
tested (8 vitest cases); proxy-allow head list stays green (25 cases).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Mount AnalyticsProvider (product: console, cookie/same-origin — the tenant is
stamped server-side, no getToken) inside the session so identify can bind the
signed-in actor. AnalyticsBridge fires route-change pageviews via
usePageview(usePathname()) and identifies the person by the stable owner/name
actor id (never email; anonymous sessions skipped).
Capture EVENTS at the real surfaces:
- signup funnel (SignInForm): SIGNUP_VIEWED on the create-account view,
SIGNUP_SUBMITTED on submit, SIGNUP_COMPLETED on account creation (self-serve
signup has no separate verify step).
- first action (onboarding LaunchStep): FIRST_ACTION on the first product tile.
- upgrade intent (PlansModule): PRICING_VIEWED on view, PLAN_CLICKED +
CHECKOUT_STARTED on a plan CTA.
- feature usage: API_KEY_CREATED (ApiKeysModule), PROJECT_CREATED / APP_CREATED
/ DEPLOY_STARTED (PaaS CreateAppForm), PROJECT_CREATED (ProjectsModule).
No secrets/PII captured — only user/org ids and non-sensitive props.
@hanzo/usage 0.1.4 ships the gui/Tamagui <UsagePanel> at its own ./panel
source subpath (./react is the DOM <UsageDashboard>). Point AiUsageModule at
./panel and bump the dep. typecheck green.
Replace the ai-metrics LivingOverview client-side re-derivation with the ONE
<UsagePanel> (@hanzo/usage/react) reading GET /v1/get-cloud-usages via a new
CloudUsageApi (same-origin /v1 proxy, cookie auth). Transpile @hanzo/usage so
its source panel compiles in the client bundle, and drop its now-unnecessary
serverExternalPackages entry (its headless entry carries no node built-ins).
These 6 commits (QR device login, Code dashboard, Treasury, OSS Authors) were
never on origin main; publish them forward. QR device login is the console side
of the device-flow login loop — /auth/device BFF starts+polls IAM with client
hanzo-cloud, which now carries the device_code grant (universe ed55bdaa). Bump
128→129 so a clean image builds (origin already tagged 128 without QR).
The Treasury admin dashboard (reserve fund + revenue-share + backed payouts +
Hanzo L1 anchor, v8.4.112) and the OSS Authors royalty product (v8.4.111) that
this branch introduced are already present in main, merged via PR #122 and #120.
The core files (TreasuryAdminModule, admin-treasury{,.test}, AuthorsAdminModule,
admin-authors) are byte-identical; main additionally carries the /cloud->/v1
bearer-proxy migration on the two authors files, so main is strictly newer.
Record the branch as integrated with -s ours (keep main's tree unchanged) so the
stale duplicate is not re-applied and the branch can be retired cleanly.
Open console.<brand> on ANY machine, scan the QR with your phone, sign in +
approve at the brand IAM, and this tab's session starts — no password typed
on the machine at hand.
- session.ts: deviceCodeGrant(deviceCode, clientId) — public-client device
poll; maps authorization_pending/slow_down → pending, expired_token →
expired, success seals the session (reuses sealSession). SessionError now
carries the OAuth error code so the poll tells the states apart.
- app/auth/device/route.ts BFF: action=start proxies IAM's device endpoint at
the public issuer (so verification_uri is scannable), action=poll redeems and
sets the exact sealed cookies /auth/session uses.
- iam-device.ts: thin same-origin client wire over that route.
- QrSignIn.tsx: renders the QR of verification_uri_complete + the user code,
polls at the IAM cadence, and on approval reloads the session → '/'. Reached
via a "Sign in with QR code" affordance on SignInForm, so console.<brand> on
any device offers it.
- dep: qrcode.react 4.2.0 (pinned).
Tested: deviceCodeGrant states (session.test.ts, fetch-mocked); typecheck +
build green.
The console predicate was already owner=='admin' (isSuperAdminOwner / gateAllows /
accountOf); accessClaims still projected a JWT isSuperAdmin boolean claim that
accountOf overrode. The gateway now drops the isSuperAdmin/isGlobalAdmin boolean
entirely, so this makes accessClaims owner-canonical too: SuperAdmin has ONE signal
(owner==admin org), no boolean claim read anywhere. Org-scoped isAdmin untouched.
SuperAdmin ⟺ the principal's IAM org (owner) IS the reserved 'admin' org — the same
equality IAM's User.IsSuperAdmin() uses (user.Owner == conf.AdminOrg). IAM DERIVES its
isSuperAdmin claim from that equality, so reading the claim as well was two signals for
one fact.
- config: export isSuperAdminOwner(owner) — THE predicate, one place.
- session.accountOf + auth/admin.isSuperAdminAccount: use it; drop the redundant
read entirely. No claim, no fallback, no compat.
- test: strengthened — a claim on a NON-admin-org account can NEVER confer SuperAdmin
(a second signal would be forgeable); admin-org membership alone decides.
tsc 0 errors; auth/session/identity/admin-policy suites 55/55 green.
Merges feat/sentry-dashboard into main. Sentry lands as a host-branded product FACE
(sentry.hanzo.ai = the hanzo brand wearing a Sentry error/log/trace shell over /v1/sentry).
RECONCILIATION — the social-mode lane (marketing/ads/social) had added more per-mode booleans
(config.{marketingOnly,adsOnly,socialOnly} + is{Marketing,Ads,Social}Host + {MARKETING,ADS,
SOCIAL}_ID + N per-mode branches in visibleCatalog/page). This branch generalized the billing
special-case into ONE `shell`/`ShellId` model. Resolved by DECOMPLECTING all FIVE faces into
that ONE model (a name is a value in one namespace, not N parallel booleans):
- config: ShellId = console|billing|marketing|ads|social|sentry; ONE `shellFromHost` resolver
(host prefix / NEXT_PUBLIC_*_ONLY / NEXT_PUBLIC_PRODUCT_SHELL) drives everything. The four
{billing,marketing,ads,social}Only booleans are now DERIVED aliases (shell === '<x>'), kept
for existing call sites + tests. Orthogonal to brand — a face never crosses a brand.
- shell.ts: ONE descriptor (rootId/wordmark/home/indexLabel) per face — the single source of
each face's scope. Replaces the {BILLING,MARKETING,ADS,SOCIAL}_ID consts (removed).
- registry: visibleCatalog + visibleCatalogByCategory collapse the 4 per-mode branches into
ONE isProductShell(config.shell) path (shellFor().rootId); e.shell hides only face-scoped
entries (sentry) from the console — marketing/ads/social stay normal Apps products.
- page: ONE shellHome redirect for every face. DashboardShell: ONE isProductShell face-nav
branch (billing/sentry = root sub-pages; single-screen marketing/ads/social = a lone
Overview; product wordmark beside the Hanzo mark — billing keeps its legacy mark-only look).
ZERO regression — all FIVE faces resolve, filter the catalog, and route home (unit-asserted).
package.json 8.4.128. tsc clean; vitest 2295/2295 (194 files); next build ✓; all 5 face roots
+ sentry sub-routes serve 200 at runtime.
sentry.hanzo.ai is the SAME console app, host-branded into a Sentry PRODUCT shell —
Hanzo IAM + @hanzo/gui identity, product-labelled "Sentry" (no upstream Sentry look).
Extends the brand-by-host machinery with an orthogonal product-SHELL concept and adds
the full /v1/sentry dashboard. Reuses the existing primitives; composition, not greenfield.
- config: ShellId + shellFromHost (console/billing/sentry faces), orthogonal to brand —
a shell never crosses a brand; billingOnly is now the derived `shell === 'billing'`.
- lib/products/shell.ts: the pure per-face descriptor (root module, wordmark, home) — the
ONE source the nav + home redirect + catalog gate share. + shell.test.ts.
- lib/api/sentry.ts: the /v1/sentry client — projects (+ DSN/key rotate), issues (list/get/
update/events), discover, events, logs, traces (+ detail), stats. Version-less same-origin
BFF (originV1Url, session cookie only), org server-enforced. Defensive normalizers + tests.
- components/products/sentry/*: Issues (search/status/sort/period/project + KPIs + sparklines),
IssueDetail (stack trace + source context, breadcrumbs, tags, linked trace, resolve/ignore/
reopen, occurrence timeline), Discover (filter/agg/group-by builder + table + chart), Logs
(level filter + detail rail), Traces (list + span waterfall), Monitor (event/error timeseries),
Projects (DSN + SDK snippet — CLEAN path, NO /api/), Members (composes Hanzo IAM TeamApi).
- SentryModule: ONE module routed by :tab (+ :tab/:id detail); the shell nav branch is
generalized (billing + sentry) from the descriptor, the home redirect too. Sub-pages derive
from SENTRY_TABS (one source). `logs` base-slug precedence proven in sentry-routing.test.ts.
- registry: sentry entry (shell-scoped → hidden from the full console); proxy-allow `sentry` head.
Verify: tsc clean; vitest 2267/2267 (193 files, +52 new); next build ✓; all 10 /sentry routes
serve 200 at runtime. Panels are contract-wired — the live /v1/sentry backend + the
authenticated Playwright proof against sentry.hanzo.ai are the post-deploy gate.
Flesh the SocialModule to real parity with the live social-frontend over the extended
native /v1/social surface:
- Publish action: a post detail drawer with a real Publish now button
(POST /v1/social/posts/:id/publish) that surfaces the honest outcome — external id on
success, or the exact missing-credentials 503 on the fail-closed provider seam.
- Compose: draft / schedule / publish-now modes; when the target network isn't
configured to publish, an inline honest warning naming the missing OAuth-app creds
(from GET /v1/social/providers) — never a fabricated success.
- Calendar/list: a view toggle — the existing list table plus a calendar (agenda)
view grouping timed posts by day.
- Connect flow: the connect panel shows LIVE per-network publish-readiness
(configured / needs X_API_KEY, ...) beside the account add.
lib/api/social.ts gains providers() + posts.publish() + the server-managed post result
fields (accountId/externalId/error), all defensively normalized. social.test.ts (8 tests)
pins the same-origin /v1/social paths (providers, publish) + the normalizers. tsc --noEmit
clean; vitest green.
Console half of the new /v1/ads domain seam (one console, host-resolved modes,
orthogonal /v1 domains). Mirrors the billing-only shell EXACTLY:
- config: adsOnly field + isAdsHost/isAds (ads.<brand> prefix, NEXT_PUBLIC_ADS_ONLY
override) — the host->mode twin of billingOnly.
- registry: a real Ads CatalogEntry (id 'ads', Apps) rendering AdsModule;
visibleCatalog/visibleCatalogByCategory filter to it when adsOnly, same shape as billing.
- page: home route redirects to /ads in ads-only mode.
- AdsApi + AdsModule: thin, honest per-org view over the REAL cloud /v1/ads surface
(summary + campaign CRUD), same client path as CRM; 'ads' head allow-listed in
proxy-allow CLOUD_HEADS.
tsc --noEmit green; config test +5 ads cases (26 pass).
The console half of the new /v1/social domain seam — the host→mode twin of
Billing/Marketing. social.<brand> (config.socialOnly, isSocialHost, or
NEXT_PUBLIC_SOCIAL_ONLY=1) boots the SAME console image into the ONE Social
product; the catalog is filtered to it and home redirects to /social.
- config: socialOnly on ConsoleConfig + isSocialHost/isSocial, resolved in
resolveConfig (mirrors billingOnly 1:1). 5 new host tests (26/26 pass).
- SocialModule: per-org Posts + Accounts CRUD + real summary over cloud
/v1/social, through the /v1 user-bearer BFF. Honest loading/error/empty
states, never fabricated rows.
- lib/api/social.ts: thin REST client (accounts + posts + summary),
defensive normalizers — twin of crm.ts/marketing.ts.
- registry: Social CatalogEntry + SOCIAL_ID + visibleCatalog(ByCategory)
social-only branch. proxy-allow: 'social' CLOUD_HEAD.
Typecheck: tsc --noEmit GREEN (0 errors).
Console half of the new /v1/marketing domain seam (one console, host-resolved
modes, orthogonal /v1 domains). Mirrors the billing-only shell EXACTLY:
- config: marketingOnly field + isMarketingHost/isMarketing (marketing.<brand>
prefix, NEXT_PUBLIC_MARKETING_ONLY override) — the host->mode twin of billingOnly.
- registry: a real Marketing CatalogEntry (id 'marketing', Apps) rendering
MarketingModule; visibleCatalog/visibleCatalogByCategory filter to it when
marketingOnly, same shape as billing.
- page: home route redirects to /marketing in marketing-only mode.
- MarketingApi + MarketingModule: thin, honest per-org view over the REAL cloud
/v1/marketing surface (summary + campaign CRUD), same client path as CRM;
'marketing' head allow-listed in proxy-allow CLOUD_HEADS.
tsc --noEmit green; config test +5 marketing cases (26 pass).
New customer surface at /bot/run (BotsConsole) over cloud POST /v1/bots/run
(BotsApi.run): boot a desktop/terminal computer, run a computer-using bot on it
against a task, and attach live over the returned VNC session — org-scoped +
metered a flat per-run 'bot' fee (402 → add-funds nudge). Registered as the
'run' route on the customer-facing 'bot' product (distinct from BotModule's
gateway status and the admin BotsModule fleet-spend analytics).
Launch + watch today; a persistent runs list + stop need the cloud endpoints the
launch-only bots surface deliberately lacks (GET /v1/bots + stop, proxying the
bot-gateway's live nodes) — session history is client-side until then. VNC embed
needs the bot gateway to allow this console origin as a frame-ancestor; the
open-in-new-tab fallback works regardless.
Flip the Observe read plane (O11yApi.traces/observations/sessions + their trace/
session detail) from the /v1/evals cloud_usage projection to the NATIVE o11y span
plane (/v1/o11y), the declared observation-of-record. Step 3 of the unified AI-
observability collapse; the CTO call flagged open since v8.4.124.
- traces/observations/sessions read /v1/o11y/{traces,observations,sessions} via the
/v1 user-bearer BFF; unwrap o11y's { status, data } over { items, offset, limit };
map the native camelCase view-models (view.go) with honest guards (non-finite
cost/latency/tokens -> null em dash, never fabricated).
- trace(id)/session(id) DETAIL composed from the list views filtered by traceId/
sessionId (o11y exposes no detail endpoint). trace(id) header roll-ups come from the
SERVER-aggregated trace row (grouped over ALL spans) so a >200-span trace never
undercounts; the waterfall list is bounded to 200. Inline scores stay EVAL scores for
that trace (EvalsApi.listScoresTyped by traceId) — nothing lost.
- SCORES + score-configs + datasets/evaluators/runs STAY on /v1/evals. One way per
domain. o11y llmobs span views are org-scoped fail-closed on gen_ai.hanzo.org_id
(C1), server-set from the validated X-Org-Id — tenant-safe.
Red review (SHIP): LOW-1 trace-header undercount fixed via the server-aggregated row;
INFO-1 the 5 eval-domain trace/observation/session readers kept as a documented
RETAINED SDK surface (not re-wired into O11yApi) — evals.ts scope doc corrected.
Tests: o11y.test.ts (23) pure adapters + honesty guards + the flip contract
(traces/observations/sessions -> /v1/o11y, scores/score-configs -> /v1/evals, LOW-1
server-aggregated header + fallback). vitest 40/40 (o11y+evals) green; tsc clean for
changed files.
Match the unified Hanzo app-shell (hanzo.app + hanzo.chat): the top-left
is now the white-label brand logomark ALONE (host-derived BrandMark — Hanzo H /
Lux / Zoo / Pars per host, never hardcoded, no wordmark/product-name/letter-H
text) with a right-click brand context menu (Settings · Brand · Docs · About),
and the org switcher + user/account menu + wallet consolidate into ONE bottom-left
cluster (was split user-on-top / wallet-below). Applied across the expanded rail,
the collapsed icon rail, the billing-only shell, and the mobile drawer (all mount
SidebarNav). Reuses the existing BrandMark/getBrand white-label resolver, OrgSwitcher,
SidebarWallet, and Popover menu primitive — no new systems, @hanzo/gui engine unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The o11y public contract is FLAT and version-less (one /v1/, no nested
/api/vN). The upstream SigNoz engine version is an internal impl detail
resolved SERVER-SIDE in cloud (clients/o11y), never leaked into a route.
- telemetry.ts: SuperAdmin VM proxy o11y/vm/api/v1/{query,query_range}
-> o11y/vm/{query,query_range} (cloud vmproxy.go calls VM api/v1/* inside).
- apm.ts: composite builder query COMPOSITE_QUERY_RANGE 'api/v3/query_range'
-> 'query_range' (cloud query.go resolves the flat path to the v3 engine).
- proxy-allow.ts: the single `o11y` head already admits every o11y sub-path;
drop the dead `allowTelemetrySurface`/`TELEMETRY_READ` allowlist and the
now-dead `/telemetry` Next route (stripped by static export; replaced by
the SuperAdmin VM proxy — one and one way).
SuperAdmin gate + {up,sum(up),count(up)} allowlist preserved (cloud-side).
No `api/v1/query` / `api/v3/query_range` strings remain in console source.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StatusModule reads the whole platform's VictoriaMetrics up{} inventory through
the new SuperAdmin-gated cloud VM proxy (/v1/o11y/vm/*), which 403s a non-super
caller. Without gating the ROUTE, a customer landing on /status hits that proxy
and gets a 403 console error + an error card.
Mark the `status` registry entry `admin: true` so ProductRoute renders the
graceful AdminManagedNotice for non-super callers (no proxy call, no console
error) and only a SuperAdmin renders StatusModule + the live board — matching
MetricsModule's PlatformInfraHealth gating. Customers never hit it (role-gated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Variables tab: real add/edit/delete env + secret editor (write-only secrets:
Keep/Replace, never revealed) writing through setEnv (PUT .../env). Kept sealed
secrets submit an empty value (preserve-on-empty) so an edit never wipes KMS.
- Logs tab: live-tail the latest deployment's logs while a build/deploy is in
progress (poll — endpoint is a snapshot, no SSE — reuse railway phase, auto-scroll,
stop at terminal).
- One create/connect-repo flow: extract shared CreateAppForm; the canvas 'New
service' now uses it (was a CLI-text stub), same path as the Applications board.
- Domains tab on the canvas drawer reuses DomainsPanel (add/remove/verify), not
verify-only.
- Pure env-editor helpers + unit tests (toEnvDrafts/draftsToEnv/validateEnvDrafts).
The /metrics and /status SuperAdmin infra-health board called the console's
Next.js `/telemetry/[...path]` server route, which the static-export embed
(cloud go:embeds console as output:'export') STRIPS — so a browser call to
`/telemetry/api/v1/query` 404s (the last console error).
Repoint TelemetryApi's transport from `/telemetry/*` to the same-origin,
versionless cloud proxy `/v1/o11y/vm/api/v1/{query,query_range}`
(cloudProxyV1Url → clients/o11y/vmproxy.go). Cloud gates it to platform
SuperAdmins, allowlists the query to exactly {up, sum(up), count(up)}, and
returns VM's native Prometheus envelope verbatim — so parseInstant/parseRange
are unchanged. One shared line fixes both MetricsModule and StatusModule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AiAccessStep "Connect a provider login" card was a disabled "coming soon"
stub. The backend OAuth is now live (ai#85: GET /v1/ai/connections/:provider/
authorize + callback, KMS-sealed), so flip it to a real connect button.
- `AiConnectionsApi.authorizeUrl(provider)` — fetch-then-redirect: GETs
`…/connections/<provider>/authorize?format=json` → `{ authorizeUrl }` (tolerates
authorize_url / url), then the step redirects the browser to the provider consent
screen; the backend seals the token on its callback. Records the `connect` choice.
- `/ai` proxy allow-list: add the `v1/ai/connections/<provider>/authorize` sub-path
(a narrow regex, not a general tunnel). The callback is backend↔provider, never
through this proxy.
- Honest states: a provider whose OAuth app creds aren't provisioned returns 503 →
"not available on this deployment yet" for that provider (provisioning is a
separate ops step); other errors show a retry message. Providers are probed
lazily on click (no eager authorize calls that would mint dangling OAuth state).
Tests: +6 (ai-connections.test.ts) — authorize path/format, camel+snake+bare URL
normalization, throw-on-missing, 503 propagation, provider list. tsc + next build
clean; full vitest green except a pre-existing unrelated apm-service-scope failure
on main.
Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The v3 traces list builder HARD-fails with 'select columns cannot be empty for
panelType list' (500) when a noop list query carries no selectColumns — the recent-
traces widget on 6 product pages 500'd. Add the display columns normalizeTraceSpan
reads (name, duration_nano, response_status_code) as materialized static trace
columns. Logs list stays empty (its noop path returns a default row set — verified
200). Validated live: traces+selectColumns -> 200 with real span data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an Errors (Issues) product under Observe, reading the o11y errortracking
module over the SAME version-less, IAM-scoped /v1/o11y/* BFF as Service Map/Logs:
- lib/api/apm.ts: Issue/Occurrence/IssueDetail types + defensive normalizers
(unwrap the {status,data} envelope, tolerate garbage) + ErrorTrackingApi
(listIssues/getIssue/updateIssue). Org scope is server-enforced.
- components/products/ErrorsModule.tsx: status tabs, KPI band (issues/unresolved/
regressed/events), by-level Donut, issues table, and a detail SlideOver with the
latest occurrence, stack trace, and resolve/ignore/reopen actions. Dependency-free
inline-SVG charts (CSP-safe); honest RuntimeNotice/empty states, never fabricated.
- registry.tsx: one Observe catalog entry ('errors', routes ''|':id').
- errortracking.test.ts: 8 normalizer tests (envelope unwrap, defaults, garbage).
tsc clean (my files); vitest 8/8. Lights up when cloud bumps its embedded o11y
dep to the errortracking build (same gating as the llmobs org-scope fix).
Native Code module surfacing the LIVE per-org /v1/code code-intelligence
engine, mirroring the Agents module's structure/idiom exactly.
- lib/api/code.ts — CodeApi (search/ask/context) over the same-origin /v1
user-bearer proxy, org-scoped SERVER-SIDE (never a client-side org param);
defensive normalizers + pure derivers/formatters. +21 unit tests.
- components/products/CodeModule.tsx + code/parts.tsx — a hybrid SEARCH box
(query + hybrid|text|symbol|semantic mode) → clickable file:line results
table (row → span detail pane), and an ASK panel rendering answer +
citations[] as file:line refs (click reveals the cited span via search).
Honest states throughout (inert-until-queried, "not connected" on a 404
route, BackendStateCard on 403/5xx, degraded banner) — never fabricated
data, exactly like Agents.
- Registered `code` in the products registry (Dev, Code2 icon) and
allow-listed the `code` head in proxy-allow.ts (+ test).
tsc clean; vitest +48 new pass (the lone suite failure is a pre-existing
origin/main apm-service-scope test, untouched here); next build green.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Native Code module surfacing the LIVE per-org /v1/code code-intelligence
engine, mirroring the Agents module's structure/idiom exactly.
- lib/api/code.ts — CodeApi (search/ask/context) over the same-origin /v1
user-bearer proxy, org-scoped SERVER-SIDE (never a client-side org param);
defensive normalizers + pure derivers/formatters. +21 unit tests.
- components/products/CodeModule.tsx + code/parts.tsx — a hybrid SEARCH box
(query + hybrid|text|symbol|semantic mode) → clickable file:line results
table (row → span detail pane), and an ASK panel rendering answer +
citations[] as file:line refs (click reveals the cited span via search).
Honest states throughout (inert-until-queried, "not connected" on a 404
route, BackendStateCard on 403/5xx, degraded banner) — never fabricated
data, exactly like Agents.
- Registered `code` in the products registry (Dev, Code2 icon) and
allow-listed the `code` head in proxy-allow.ts (+ test).
tsc clean; vitest +48 new pass (the lone suite failure is a pre-existing
origin/main apm-service-scope test, untouched here); next build green.
Fills the two stubbed seams in the App Platform canvas (@hanzo/canvas):
1. Per-service metrics — the card sparkline + drawer Metrics tab now show REAL
per-service RED metrics from cloud's o11y surface (GET /v1/o11y/metrics?product=
<slug>, clients/o11y): requests, error rate, and p95 latency time-series, org-
scoped server-side. New `lib/api/o11y-metrics.ts` client (honest states: 200
honest-empty for a service with no telemetry; connected:false on 503/404/401/403;
400 = bad slug → honest-empty for that one app — never throws, never a fabricated
chart). `platform-apps/metrics.ts` folds the requests series into the card's
ServiceMetric (undefined = no sparkline, the exact prior honest state) and fetches
the visible apps' metrics concurrency-capped. The drawer Metrics tab renders the
full requests/errors/latency set with a 1h/6h/24h window toggle. Per-service
CPU/memory are NOT exposed by this RED (trace-derived) read, so they are labeled
honestly as not-exposed rather than estimated.
2. Dependency edges — the platform store declares no service bindings (verified in
cloud clients/platform: no dependency/link/binding model), so instead of an
always-empty declared-deps endpoint we overlay the REAL OBSERVED runtime
dependency graph from o11y (ApmApi.dependencies → /v1/o11y/dependency_graph). A
solid `dependency` edge is drawn ONLY where both endpoints resolve to apps in the
canvas (matched by OTel service.name), and an observed dependency supersedes the
env-var-derived `reference` guess for the same pair. Env-var references remain the
declared-intent hint when no telemetry links two apps.
`buildProjectCanvas` gains an optional `extras` arg (metricByApp + serviceDeps) so
the fold stays pure; the module fetches the live o11y signals separately. No
@hanzo/canvas change needed — MetricSparkline/ServiceMetric already accept the data.
Tests: +24 (canvas metric injection + dependency-edge supersession/scoping,
o11y-metrics normalizer + honest transport states, metrics folds). Full suite
184 files / 2149 pass; tsc clean; next build ✓ (23/23).
Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The console's logs/traces list widget builds the v3 composite payload
(compositeQuery.{queryType,builderQueries}) with a matching v3 response parser
(parseListRows over data.result[].list). It posted to the version-less
/v1/o11y/query_range alias, which the embedded o11y resolves to the HIGHEST
version (v5) — whose composite query accepts only {queries:[…]} and 400s the v3
shape (unknown field "queryType"). This broke the overview-metrics widget on 6
product pages (studio/gateway/cli/registry/desktop/console).
Pin both composite calls to the explicit /v1/o11y/api/v3/query_range (verified
200 against live embed with the exact payload). Request+response stay a matched
v3 pair; the v3 handler is registered and live. No backend change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follows hanzoai/cloud removing the /v1/console/* API namespace — "console" is just
our FE name, so every route moves to its REAL domain. Forwards-only, no dual-path
fallback:
keys.ts originV1Url('console/keys') → originV1Url('iam/keys')
wallet.ts v1Url('console/topup/wallet') → v1Url('commerce/topup/wallet')
OrgOnboarding + OrgSwitcher v1Url('console/onboard') → v1Url('iam/onboard')
csrf.ts GET /v1/console/csrf → GET /v1/csrf
CSRF_WRITE_PREFIXES becomes SPECIFIC — ['/v1/iam/keys','/v1/iam/onboard','/v1/billing/',
'/v1/commerce/'] — NOT a broad '/v1/iam/', so the SPA's IAM login/signin writes still do
NOT trigger a spurious pre-auth CSRF mint (csrf.test.ts asserts /v1/iam/login|signin stay
false while the four gated surfaces stay true). Each call keeps its exact mechanism
(same-origin originV1Url for keys, cross-origin v1Url for onboard/topup) — only the path
segment changes.
Verified: npm run typecheck (0 errors), npm test (2125/2125, incl. csrf), npm run build
(Compiled successfully). Zero '/v1/console' / 'clients/console' references remain.
Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
App Platform (and the org-wide Map) render as a Railway-style project canvas
over the live /v1/platform + /v1/<kind> data, using the reusable @hanzo/canvas
components (one canvas implementation, reused across both surfaces).
- PlatformAppsModule: upgraded from a table into the project canvas — env +
project switchers, ProjectCanvas of the org's apps + their domains + the
managed data they reference (honest env-derived edges), rich ServiceDetailDrawer
(Overview/Deployments/Variables/Metrics/Logs/Domains/SBOM — reuses the existing
SBOM/logs/domains rendering as tab content, not duplicated), "+ New service"
affordance (honest CLI/API paths), StatCards, and honest loading/empty/error.
- MapModule: migrated onto @hanzo/canvas (ProjectCanvas + ServiceDetailDrawer),
keeping the pure buildGraph fold; the bespoke MapCanvas/nodes/presentation are
removed (one node card, one canvas — DRY).
- platform-apps/canvas.ts: pure fold PlatformApp[] + resources -> the generic
node/edge model, honest edges only (unit-tested, 7 tests). subsystems.ts: the
curated /v1/<svc> capability catalog so a node shows its Hanzo capability.
- Metrics per app are an HONEST empty state (no fabricated chart) — documented
seam to wire o11y/usage. Dependency edges are env-derived — documented seam to
wire real dependency data.
tsc clean; vitest 2132/2132; next build green.
Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The native @hanzo/gui Tracker (task #58, replaced Huly/hanzo.team) is a first-class
Hanzo Cloud work surface, peer of the project HUB. Add it to ALWAYS_ON_PRODUCTS so it
shows in the sidebar + ⌘K palette + launcher for every org (it was entitlement-gated,
so it rendered only via a direct /tracker URL and never appeared in nav).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The vector kind's createdAt is a numeric epoch; `?? ''` only guards null/undefined,
so `(number).localeCompare` threw 'localeCompare is not a function' and crashed the
whole /vector module render (surfaced once deep-link module rendering was fixed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one-binary cloud embed is a Next output:'export' static build; cloud serves
the ROOT (dashboard)/page.tsx index.html for EVERY deep link (a static export
can't pre-generate arbitrary product slugs). So /models, /chat, /tracker … —
direct load AND client nav that hard-falls-back — rendered the home Overview,
never the module. No product module surfaced in the embed.
Fix: the home page resolves the LIVE path via usePathname() and hands any real
product route to the shared ProductRoute renderer (extracted from the [...slug]
catch-all — one definition, both entry points). mounted-gated so the first
client render matches the exported home ('/') — no hydration mismatch. On a real
Next server the home only renders for '/', so behavior there is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
admin.hanzo.ai served the console SPA correctly (gated by admin-guard, on the
new console — NOT legacy console2) and /v1/admin/* returned real data, but the
(dashboard) shell wraps EVERY authenticated surface in <WaitlistGate>, which has
no admin bypass. So the operator superuser (z@hanzo.ai, owner=admin) saw the
consumer product waitlist panel instead of the operator cockpit on admin.hanzo.ai.
The operator cockpit is a distinct concern from consumer product rollout, so an
operator is never held behind the line:
- server (the access authority): waitlistAccess() short-circuits hasAccess=true on
an admin host (isAdminHost) before consulting the waitlist plugin.
- client (WaitlistGate): mirrors it — an admin host OR a super (platform) admin
disables the gate, so the cockpit never even flashes the waitlist panel.
Real authorization to admin.<brand> + /v1/admin/* is unchanged: admin-guard
ForwardAuth (PKCE via hanzo.id, org=admin) + the cloud global-admin gate still
enforce access. This only lifts the consumer waitlist UX off the operator surface.
Tests: +3 server waitlistAccess admin-host-bypass cases; tsc clean; full vitest
2082+ green; next build ✓.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Live verify of v8.4.125 (Dave/maxpower org admin) found platform absent from the
entitlement-gated customer sidebar/Apps map. The project HUB is a first-class core
capability, so add 'platform' to ALWAYS_ON_PRODUCTS — visible for every org.
tsc clean; vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New first-class `Platform` product (registry id `platform`, Platform category,
routes '' | ':name') → sidebar + Apps map + launcher + shared subpages. It's the
project HUB: create an IAM-native project (ProjectApi, name slugified so
name === deploy slug === the ?project= key), drag-drop a .zip/.tar.gz (or a
client-packed folder) to deploy over the embedded PaaS static engine
(/v1/platform/sites/*), view deployments with status/logs, bind custom domains,
and edit config. Cross-surface deep links on the ONE shared IAM project id —
Edit → hanzo.app/dev?project=<id>, Chat → hanzo.chat/?project=<id> — plus an
inbound ?project= handler that scopes + opens the hub.
- lib/api/platform-sites.ts (+ contract test): the /v1/platform/sites client.
- bearer-proxy: forward a NON-JSON body VERBATIM (bytes + Content-Type), never
text-decode/re-stamp application/json — unblocks binary artifact upload for the
ONE shared proxy; client.restPostRaw posts the artifact (keeps 401-refresh).
- lib/deploy/{archive,drop}.ts: pure ustar tar builder + native gzip + folder walk.
- lib/products/cross-surface.ts: ?project= links + slug helper (config.chatUrl added).
- ProjectApi.create gains optional displayName (additive). `projects` stays the
scope picker (no duplicate); models/billing-band/single-level-nav untouched.
tsc clean; vitest 2122/2122 (+48); next build ✓.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guided post-signup wizard shown once (resumable, skippable) via a new
OnboardingGate in the dashboard layout. Reuses existing real surfaces:
- 2FA: MfaApi -> /console/mfa -> IAM mfa/setup
- consent: account preference (data-sharing default OFF) + local guard
- team: TeamApi confirm/rename org
- trial credits: Square hosted element + BillingApi.createPaymentMethod + welcome + balance
- AI access: BYO keys -> real KMS-sealed /v1/ai/connections (new AiConnectionsApi,
allow-listed in the /ai proxy); Hanzo router via AiAccountsApi.saveSettings;
provider-login OAuth is an honest coming-soon (backend gap)
- first action: deep-link CTAs
tsc clean; vitest 2074/2074 (+11); next build green.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Upgrade the topbar OrgSwitcher from a load-everything dropdown into a
Vercel-style, truly lazy switcher that scales to thousands of orgs.
- Lazy-load / paginated search: new useOrgList hook fetches ONE page of
get-organizations at a time (orgQuery/ORG_PAGE_SIZE), appends via
mergeOrgs, and loads more on demand (infinite-scroll + Load more).
Search is debounced (250ms) and pushed to the server (field/value name
LIKE) so it narrows at the source; orgRows ALSO client-filters loaded
rows over name+displayName so what renders is correct even if the
backend ignores the server filter. hasMore derives from pageIsFull (no
reliance on a backend total).
- Vercel UI: Find organization search, avatar + name rows, honest
present-only plan/tier badge (tierOf — Hobby/Pro/Enterprise, omitted
when absent, never fabricated), checkmark on the current org, empty
state, and a Create organization footer wired to the existing /onboard
flow. Keyboard nav (up/down/enter/esc) mirrors CommandPalette.
- Masquerade preserved: super admin sees all orgs (lazy, paged); a
regular user sees only their own org (synthesized, unchanged, never
another tenant's). Switching still switchOrg (persist X-Org-Id +
reload). Projects stay lazy via useScope (untouched).
- Pure decisions in src/lib/org-list.ts (+16 vitest); reuses org-picker
logic (orgTitle/initialsOf) and org-scope filterOrgs — DRY.
tsc clean; vitest 2079 pass; next build ok.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The rebooted o11y backend (cloud embedded o11y v1.5.4) serves the canonical
VERSION-LESS surface /v1/o11y/<resource> (no nested v1/v3, no /api). Proven live:
GET /v1/o11y/health -> 200 {"service":"o11y","status":"ok"}; the reads
(/services, /query_range, /rules) -> 403 "no validated principal" (IAM-gated).
The Insights modules were still calling the nested-version SigNoz forms
(/v1/o11y/v1/*, /v1/o11y/v3/query_range) over a bare /v1/o11y/* (originV1Url),
which on the live ingress reaches the gateway with no minted bearer -> 403.
- apm.ts (ApmApi): version-less paths (services, dependency_graph,
service/top_operations, hosts/pods/nodes/list, listErrors, dashboards,
query_range), addressed via cloudProxyV1Url (the /cloud user-bearer proxy).
- AlertsModule -> o11y/rules; o11y.ts annotation-queues/users -> /cloud;
added O11yApi.health() (o11y/health).
- proxy-allow/ServiceMap/Logs/ProductLogs docstrings updated to the version-less
+ bearer-proxy contract.
- Tests: canonical-paths pins ApmApi.dashboards -> /cloud/v1/o11y/dashboards;
apm-service-scope pins /cloud/v1/o11y/query_range. tsc clean, vitest 1900/1900.
- e2e/insights-o11y.spec.ts: (A) unauthenticated gate proof (PASSES LIVE), (B)
authenticated render proof on admin.hanzo.ai (staged: needs the admin-org
SuperAdmin password + a browser env that renders the RNW SPA).
- Traces/Observations left on /v1/evals (LLM-trace domain w/ cost/tokens/scores);
repointing to /v1/o11y/traces is a data-domain change flagged for the CTO.
A compact health indicator in the lg+ topbar reflecting the OVERALL health of
the Hanzo cloud, pulled from the brand's Gatus status page (status.hanzo.ai).
How it pulls status: the badge fetches a same-origin /system-status BFF route
(app/system-status/route.ts) which server-side fetches
status.<brand>/api/v1/endpoints/statuses and returns a small JSON summary. This
sidesteps the status API's missing CORS header (a browser fetch cross-origin is
blocked) and matches the console's established BFF pattern — the badge renders
NATIVELY from the JSON summary (no iframe, no third-party script).
- src/lib/status/summary.ts — PURE summarizeStatuses(): collapses the Gatus feed
to { overall, total, up, down[] }; defensive (garbage → 'unknown', never throws).
- src/lib/status/summary.test.ts — vitest: operational/degraded/down/unknown +
last-result-wins + garbage input (7 tests).
- app/system-status/route.ts — GET BFF; bounded fetchWithTimeout(4s); fail-soft
→ overall:'unknown' at HTTP 200 (never 500); Cache-Control max-age=30.
- src/config/index.ts — per-brand statusUrl (status.<brand-domain>) +
NEXT_PUBLIC_STATUS_URL override, mirroring docsUrl.
- src/components/ui/SystemStatusBadge.tsx — compact dot+label pill (theme-aware
$green10/$yellow10/$red10 tokens), a Popover status panel listing any down
components + a "View full status" link; polls 60s, pauses when tab hidden,
non-blocking ("Checking…" until first response).
- src/components/DashboardShell.tsx — mounts <SystemStatusBadge/> as the first
topbar control.
Verification: vitest src/lib/status 7/7 green; tsc --noEmit clean for all changed
files (the only tsc errors in the tree are pre-existing @hanzo/usage module
resolution in unrelated ai-accounts/* files — a dep present in package.json but
not in this worktree's reused node_modules; my files import none of it). Not
deployed; no package.json version bump (release agent owns that).
Rebrand all SigNoz/signoz branding in comments, docstrings, tests, SDK
identifiers, and LLM.md to the o11y product name (case-correct):
signoz->o11y, SigNoz->O11y, Signoz->O11y.
- rename type SignozDataSource -> O11yDataSource (apm.ts + index.ts re-export)
- fix stale comment ref O11ySignozApi.logs -> ApmApi.logs
Kept (NOT branding):
- proxy-allow.ts attribution reworded to explicit 'forked from SigNoz'
- admin-o11y.ts ClickHouse table names signoz_traces/signoz_logs left intact
(real upstream SigNoz schema owned by hanzoai/datastore, not this repo)
This repo does not import the collector; no dependency added.
Add a read-only "Bill of Materials (SBOM)" section to the App Platform
deployment detail (the app-detail SlideOver), wired strictly to the backend
wire contract GET /v1/sbom/{ref}.
- data layer (lib/api/platform-apps.ts): new exported types SbomComponent +
Sbom (SbomView), and PlatformAppsApi.sbom(imageRef) — same-origin /v1
user-bearer proxy via cloudProxyV1Url, matching the file's restGet idiom;
returns null on 404 (no SBOM recorded — expected, not an error), throws on
any other non-200.
- UI (PlatformAppsModule.tsx): fetches sbom(appImageRef(app)) on SlideOver
open; reuses the shared Spinner (loading), DataTable (overflow-x scrollable
Name/Version/Type/License table, componentCount in the header) and the
existing muted-Text patterns for the "No SBOM recorded" empty state and the
"SBOM datastore unavailable" (503) note. Read-only, no mutations.
- appImageRef (platform-apps/logic.ts): one source for the image ref, shared
by the Image fact and the SBOM lookup (+ unit tests).
- proxy-allow.ts: allow-list the `sbom` cloud head so the /v1 BFF forwards it.
tsc --noEmit clean; vitest 2056/2056 (incl. +2 appImageRef, proxy-allow head).
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Systematic responsive/cross-platform pass. The v8.4.112 work already handles the big
items (no horizontal overflow on /, /models, /chat, /gpus, /billing at 390/768px;
tables scroll inside their own container; tab rows wrap; chat composer docks; coarse-
pointer tap targets are 44px; light-theme parity is intact). Concrete fixes:
- Sidebar footer no longer repeats the account identity — the new top switcher owns
it. SidebarWallet is now purely the wallet (balance → Cost, Top up, Sign out), so
the user's name/avatar isn't shown twice.
- Safe-area insets for notched devices: viewport-fit=cover exposes the insets; the
chat composer dock pads the home indicator (bottom); the SlideOver drawers inset
top (notch) + bottom (home indicator). Zero effect on devices without a cutout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
authedFetch fetched GET /v1/console/csrf for EVERY mutating request in the embed, so a
mutating request that fires BEFORE a session cookie exists (the pre-login session POST)
minted a token with no principal → 403 — the SPA's ONE remaining browser console error
(self-healing, but a logged error). Cloud only gates POST/DELETE /v1/console/{keys,
onboard,topup/wallet}, POST /v1/billing/*, and mutating /v1/commerce/* (clients/console/
console.go). Scope csrfRequired(method,url) to exactly those prefixes: money-writes still
get the token; login/session/control-plane writes no longer trigger the spurious mint.
Tests: +scoping case; 8 csrf + 12 client-retry pass; typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The embed session-bridge money path (v8.4.122: billing/commerce/keys at bare
/v1 same-origin, caller resolved from the first-party IAM cookie) is guarded by
the cloud binary's requireCSRF (clients/console/csrf.go) — POST/DELETE
/v1/console/{keys,onboard,topup/wallet}, POST /v1/billing/*, POST|PUT|PATCH|DELETE
/v1/commerce/*. An ambient-cookie write with no X-CSRF-Token is refused (403).
Wire the SPA to satisfy it, DRY, one module (src/lib/api/csrf.ts):
- csrfToken() mints from GET /v1/console/csrf (SOP hides its body from a
cross-site page), caches until ~1min pre-expiry, shares one in-flight fetch.
- applyCsrfToInit() stamps X-CSRF-Token on mutating requests in authedFetch (the
ONE fetch — covers billing/commerce/wallet via restRequest); keys.ts (raw
fetch) echoes it too. Both re-mint once on a 403 (server key resets on restart
when CONSOLE_CSRF_KEY is unset) — blue's re-fetch-on-403 contract.
- Gated on IS_EMBED + a mutating verb: a non-embed host writes through the
user-bearer BFF (Authorization ⇒ CSRF-immune), so this is a strict no-op there.
tsc clean; vitest 2048/2048 (+8 csrf: verb gate, cache, in-flight share, 403
re-mint, fail-secure null, header-shape stamping, non-embed no-op); next build ok.
Out-of-box each customer org assembles its own backend: the sidebar/launcher/
palette show ONLY the products the org has enabled (always-on essentials + its
entitled set), with an "Add product" flow to enable more. Super admins bypass
gating (see everything) and get a per-org Entitlements editor to manage any org's
enabled products after masquerading in.
- src/lib/entitlements.ts: the ONE /v1/orgs/{org}/entitlements client + pure
helpers (ALWAYS_ON_PRODUCTS, entitledSet, filterEntitled, nextEnabled). Swapping
the mock for the real backend is this file alone; until it lands the GET 404s and
the set is treated as null=UNGATED (show everything) → zero pre-launch regression.
- src/lib/entitlements-context.tsx: EntitlementsProvider/useEntitlements — ONE
shared fetch of the active org's set, wired inside SessionProvider.
- registry: visibleCatalog(showAdmin, enabled?) + visibleCatalogByCategory +
addableCatalogByCategory gate through the one filterEntitled predicate; threaded
into DashboardShell, AppLauncher, CommandPalette, CategoryOverview + search.
- AddProductPanel: the customer enable flow; EntitlementsAdminModule + registry
entry 'entitlements' (admin:true): the super-admin per-org editor.
- proxy-allow: 'orgs' head admits the org-scoped entitlements surface through /v1.
- Rename isGlobalAdmin→isSuperAdmin across the client (useIsSuperAdmin,
isSuperAdminAccount, PickerContext/LoadContext field, 'Super admin' label).
TRANSITIONAL: isSuperAdminAccount reads account.isSuperAdmin ?? account.isGlobalAdmin
so it works before/after the IAM field rename. Server projected-claim untouched.
Tests: vitest 2040 pass (incl. entitlements 16, entitlements/logic 6, admin
back-compat 6, org-picker rename 25); tsc clean; next build ✓; playwright
entitlement-sidebar spec ✓ (gated nav shows Agents, hides GPUs, Add product lists
Enable GPUs).
Co-authored-by: Hanzo AI <ai@hanzo.ai>
In IS_EMBED (the static bundle the cloud binary go:embeds) there is NO Next server,
so the service-token BFF route handlers the money clients rely on — app/billing/v1,
app/commerce/[...path], app/keys/route.ts — are stripped by the static export; a
request to them falls through to the SPA shell (HTML, not JSON), so billing showed
'not available', the API-key CTA dead-ended, and the commerce store read as empty.
The embed is same-origin with the cloud binary, which serves the SAME heads at the
CANONICAL bare /v1/* (caller resolved from the first-party IAM session cookie →
validated principal, cloud middleware_identity.go), so in embed mode:
billingProxyV1Url → <origin>/v1/billing/<path> (was /billing/v1/<path>)
commerceProxyV1Url → <origin>/v1/commerce/<path> (was /commerce/v1/<path>)
keysUrl (keys.ts) → <origin>/v1/console/keys (was /keys)
Guarded by IS_EMBED, so every non-embed console (console2/admin/brand hosts, whose
gateway-fronted ingress 403s a cookie-only bare /v1) is UNCHANGED — only the cloud
embed build sets NEXT_PUBLIC_CONSOLE_EMBED=1. cloudProxyV1Url already equals
originV1Url on main (bare /v1), so cloud heads (framework/s3/gpus/functions/…) already
resolve correctly in the embed; billing/commerce/keys were the remaining BFF paths.
+embed-paths.test.ts pins the embed contract; canonical-paths.test.ts unchanged/green.
* test(console): guard proxy allow-lists against internal-infra + privileged heads
Pin the same-origin /v1 proxy boundary to the canonical capability manifest
(hanzoai/openapi CAPABILITIES.md): assert CLOUD_HEADS + COMMERCE_HEADS never
admit an internal-infra name (principal/goja/mpc/controlplane) nor a privileged
head (iam/admin/kms), and that every head is a clean, unique, lowercase segment.
Enforces the mandate rule that internal infra never appears as a public
capability, at the console boundary — non-breaking, no allow-list widening.
* feat(signup): public open signup + waitlisted product access (referral + run-hanzod move-up)
Signup is now PUBLIC (open, no invite required) and PROTECTED; product access is
WAITLISTED with two server-attested move-up paths. One coherent system over the
waitlist Base plugin (/v1/waitlist/*), shared by console/chat/app.
Public + protected signup (/auth/signup):
- Turnstile bot wall (verifyTurnstile; config-gated on TURNSTILE_SECRET_KEY)
- per-IP sliding-window rate limit (signupLimiter, default 5/IP/hr)
- disposable-email block (isDisposableEmail)
- same-origin CSRF gate (unchanged); new signups are self-service customer orgs
(owner=personal slug, never the reserved admin org) — unchanged
- on success, best-effort join to the brand waitlist honoring a ?ref= referrer
Waitlisted product access:
- /auth/waitlist BFF resolves the signed-in email -> plugin status -> hasAccess
- WaitlistGate wraps the shell (AuthGate > WaitlistGate > OrgGate): renders the
product only at the front of the line, else the waitlist panel (position +
run-a-node + invite move-up). FAIL-OPEN: a waitlist blip never locks a user out.
- re-gatable via plugin knobs (WAITLIST_OPEN / WAITLIST_ACCESS_CAPACITY); the
console-side switch is WAITLIST_URL (unset => gate off).
SignInForm: Turnstile widget (signup mode) + ?ref= capture. Tests: rate limiter,
disposable guard, waitlist client fail-open (14 tests). typecheck + next build green.
---------
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The mainnet default was the placeholder 36900; genesis
(lux/genesis/configs/hanzo-mainnet), the CLI, and the hanzo-evm comment
all say 36963. Align it so the console network model matches the CLI
exactly (same networkID/chainID/rpc per network):
mainnet 36900 -> 36963 (rpc.hanzo.network)
testnet 36962 (rpc.testnet.hanzo.network) [unchanged]
devnet 36964 (rpc.devnet.hanzo.network) [unchanged]
local 1337 (localhost:9630) [unchanged]
Sovereign L1: networkID == evmChainID. All values stay env-overridable.
Fixes the hanzo-evm hex example (0x9024 -> 0x9063) + doc comments.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Pin the same-origin /v1 proxy boundary to the canonical capability manifest
(hanzoai/openapi CAPABILITIES.md): assert CLOUD_HEADS + COMMERCE_HEADS never
admit an internal-infra name (principal/goja/mpc/controlplane) nor a privileged
head (iam/admin/kms), and that every head is a clean, unique, lowercase segment.
Enforces the mandate rule that internal infra never appears as a public
capability, at the console boundary — non-breaking, no allow-list widening.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The network switcher now expresses ONE model — (label, networkID, evmChainID,
rpcEndpoint, apiEndpoint) — for every selectable network, in a new `lib/network.ts`.
- Stock networks wired to REAL Hanzo endpoints (env-overridable), honoring
networkID == evmChainID for the Hanzo sovereign L1:
mainnet 36900 rpc.hanzo.network (matches the deployed wallet RPC)
testnet 36962 rpc.testnet.hanzo.network (canonical genesis id)
devnet 36964 rpc.devnet.hanzo.network (canonical genesis id)
API stays same-origin for the stock tiers (they differ by X-Environment, not host,
keeping the session cookie first-party).
- Local: networkID/chainID 1337 (localnet), RPC localhost, API same-origin — a home
user running the cloud binary sees "Local" and the console talks to their binary.
- Custom: user enters networkID + EVM chainID (defaults to networkID) + RPC + optional
API endpoint; validated, persisted in localStorage, removable.
Selecting a network is ONE move: the active network's id IS the X-Environment string,
so it re-scopes every cloud call AND retargets chain/RPC/API — no parallel state, no
special-casing. `activeApiBase()` points the direct cloud client at the selected
deployment (`apiEndpoint` override, else same-origin). Existing mainnet/testnet/devnet
scoping is unchanged; the wallet's HANZO_MAINNET is now derived from the network model
(one source of truth).
Tests: 20 new (registry, resolution, validation, persistence). Full suite 1991 green,
typecheck + build green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
CTO contract: ZERO prefix before /v1/ on any cloud API call. The console
rewrote /v1/<cloudhead> -> /cloud/v1/<cloudhead> (next.config.mjs) and
cloudProxyV1Url built /cloud/v1/... directly, so the /cloud/ prefix leaked to
clients and 404'd Automations (/v1/automations/* -> /cloud/v1/automations/*).
The user-bearer BFF (mints a short-lived IAM token from the session cookie;
cookie never reaches cloud-api; org server-authoritative from the Bearer owner;
same-origin CSRF guard on mutations; least-privilege allowCloudSurface allow-list)
moves from app/cloud/[...path] to app/v1/[...path]. It re-prepends the v1/ root,
so the allow-list and upstream URL still see v1/<head>. Every guard in
forwardWithUserBearer is preserved — a PATH change, not a security change.
- Removed the CLOUD_V1_HEADS / CLOUD_INFRA_V1_HEADS / CLOUD_PRODUCT_V1_HEADS
-> /cloud/v1 rewrites in next.config.mjs; cloud heads now fall through to the
/v1 catch-all (no rewrite). cloudProxyBase deleted; cloudProxyV1Url === originV1Url.
- Kept beforeFiles dispatch (wins over the catch-all): AI heads -> /ai, admin
aggregate /v1/admin/* -> /admin/aggregate, visor catalog -> /vm, /v1/billing/*
-> /billing/v1, /v1/commerce/* -> /commerce/v1 (server-internal; client only
ever builds /v1/...).
- Scrubbed every /cloud/v1 and /cloud proxy reference across routes, rewrites,
client calls, tests, comments, and LLM.md; dead CLOUD_V1_HEADS comment refs fixed.
Acceptance (built server): GET /v1/automations/connectors -> 401 JSON (reaches
the cloud BFF, not 404, not the SPA shell); /v1/agents, /v1/platform/projects ->
401 JSON (regression OK); /v1/billing/balance -> 401 "Sign in to view billing"
(billing dispatch still wins); /v1/bogushead -> 404 JSON (allow-list intact).
git grep /cloud/v1 = ZERO. tsc + next build green; 1965/1965 unit tests pass.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
console#132 (unified Usage view + audit/logs/billing query depth) and #133 merged
at version 8.4.118 without a bump, so CI re-pushed the mutable v8.4.118 tag over the
prior build. Bump to v8.4.119 so the next build publishes a clean, distinct tag and
the git version again identifies a unique image (tag truth). The live deploy is
digest-pinned (universe#445); this fixes forward drift.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The go:embed static console has NO Next BFF, so billingProxyBase()'s /billing/v1/*
route-handler is absent → SPA fallback (200 HTML) → the overview 'Real-time usage'
tile (UsageApi.overview→fetchUsageRecords) + wallet throw 'Invalid response (HTTP
200)'. In the embed, /v1/* is served same-origin by cloud (validates the session,
resolves org from the owner claim) — proven by the working bare-/v1 heads (agents/
tracker/analytics). So IS_EMBED addresses cloud's /v1/billing/* directly. The
Next-server console (console2.hanzo.ai) keeps the /billing BFF (IS_EMBED=false) —
no regression there; embed billing was already broken so this can only improve.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decomplect the TWO automation surfaces into ONE native one. The console had
two external tiles both pointing at the standalone auto.hanzo.ai engine
(`auto` → auto.hanzo.ai, `automations` → auto.hanzo.ai/automations). Collapse
them into ONE native module backed by the go-forward `/v1/automations` engine
(706-piece catalogue, flows/runs durable on the shared Tasks engine) — like the
App Platform module is native on /v1/platform. No link-out, one nav tile.
- registry: remove the `auto` external entry; convert `automations` from
kind:external → a native module (routes + :tab; subpages Connectors/Runs).
- AutomationsModule: Flows (create/enable/disable/run/delete) · Connectors (the
706-piece catalogue, search + category filter) · Runs — over the /cloud
user-bearer proxy (org from the Bearer owner; honest loading/empty/error).
- lib/api/automations.ts: AutomationsApi + defensive normalizers (mirrors
paas.ts transport: cloudProxyV1Url → /cloud/v1/automations).
- match-core: `/auto` + `/automation` alias → `automations` (was → external
`auto`); ONE product, ONE surface, aliases preserved. The external kind stays
for the Lux/Zoo chain-app tiles (test fixture repointed to a chain app).
- proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS: add `automations`.
tsc --noEmit clean (0 errors), vitest 1970/1970 (+25 automations, match-core
updated), next build ✓.
New Startups module rendering the cloud /v1/crm/applications pipeline as a
@hanzo/data board (lanes by stage: applied→screened→qualified→credits-offered→
onboarded→rejected). Cards show company, AI score, tier-1 flag, suggested credits;
card opens a SlideOver drawer with all submitted data, the AI screen (score/tier1/
credits/summary + copy-able draft reply), stage timeline, stage-advance buttons
(PATCH via the server stage machine; drag also advances), and a grant-credits deep
link into billing. StartupsApi mirrors CrmApi (originV1Url → /cloud bearer proxy).
Registry: one import + one Apps entry.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
- Usage (new product, Observe): the org's unified footprint on one screen —
KPI band (spend/MTD/balance/LLM tokens+spend/machines/GPUs), spend-over-time +
spend-by-category charts, a CSV-exportable cost breakdown, and per-source
connected/not-connected badges. Backed by GET /v1/usage/summary (+ visor
inventory), org-scoped via the /cloud bearer proxy.
- Audit (upgraded to enterprise grade): filters (time/actor/action/resource+id/
result), real server pagination, a per-event detail drawer with the hash-chain
linkage (immutability evidence), and CSV export. Now backed by the org-scoped
cloud audit trail (GET /v1/audit) instead of the IAM record list.
- Logs: a query builder on the application-logs lens (severity/service/
contains-text over the o11y time-range query) + localStorage saved views +
CSV export of visible rows; contains-text + CSV added to the request lens too.
- Billing Reports: CSV export of the full filtered usage breakdown.
- Shared: src/lib/csv.ts (RFC-4180 serializer + browser download, one place);
api clients usage-summary.ts + audit.ts; register usage/audit as /cloud proxy
heads (next.config CLOUD_V1_HEADS + proxy-allow CLOUD_HEADS).
tsc 0, vitest 1679 green, next build ok.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The hanzoai/cloud go:embed serves console2 as a static SPA with NO Node BFF
runtime, so the server-side session routes (/auth/refresh, /auth/session) and the
/billing/v1/me/welcome proxy don't exist — their client POSTs fall through to the
GET-only SPA fallback and 405 on every load (3 console errors/load, observed via
live playwright E2E as z@hanzo.ai). The console already runs on the casibase
session there (login→org→modules→real data all work while these 405'd), so the
probes are pure noise + a doomed session-rotation attempt.
Gate them behind IS_EMBED (NEXT_PUBLIC_CONSOLE_EMBED=1, set by build-embed.mjs):
in the embed, refreshSession()/consoleGet()/establishSession()/signout-DELETE and
the welcome-grant self-heal skip the fetch and fall back to the casibase session —
identical behavior, zero 405s. The Next-server console (console2.hanzo.ai,
admin.hanzo.ai) leaves IS_EMBED false and keeps the full durable-session BFF.
Tests: refresh.test.ts adds an embed case (skips fetch, resolves false); all 5
refresh + 22 canonical-path tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Smart Routing toggle was a purely-local sealed-cookie preference. Ops can now
set a per-org default (auto_routing_active + default_session_routing) from
admin.hanzo.ai, exposed by cloud-api `GET /v1/get-routing-defaults`.
- New READ-ONLY proxy `app/ai-accounts/v1/routing-defaults` forwards the caller's
minted user bearer to cloud-api (org = token owner), same auth pattern as `/cloud`.
Does NOT touch the org-settings write path (confused-deputy escalation — reads only).
- The cookie preference becomes a tri-state user OVERRIDE (true/false/null); absent
cookie = null = follow the org default.
- One pure `resolveRouting(pref, org)` (src/lib/products/ai-accounts.ts): explicit
override wins, else org default, else off; an org that disabled routing disables the
toggle with honest copy.
- Fail-soft everywhere: `routingDefaults()` returns null on 404 (older cloud-api) /
error, so the tab works unchanged with the preference alone.
- RoutingTab shows "Organization default: On/Off — set by your admin".
Tests: +4 resolveRouting, normalizeSettings updated for tri-state. 1929 green.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Wire the AccountsTab provider badge off trackedProviderIds (derived export new
in @hanzo/usage 0.1.2), bump the dep, so each provider row shows whether it has
a live in-app usage pipeline or is connect-only. typecheck + 1925 vitest green.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
cloud-api now enforces per-org auto-routing via OrgSettings.AutoRouting
(hanzoai/ai), toggled through the global-admin-gated, non-gateway-exposed
POST /v1/update-org-settings. The Routing tab is a customer surface whose
minted hanzo-console bearer is not global-admin, and the only admin proxy
(/admin/aggregate) fail-closed-403s a non-global-admin — so there is no clean
authenticated path to write cloud-side OrgSettings, and forging one would be a
confused-deputy escalation. Keep the sealed-cookie org preference and document
the exact unlock condition; no auth bodge, no behavior change.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Add a Routing tab to the AI Accounts module explaining model:"auto" (local
zen -> cheap tier -> frontier, billed as the X-Routed-Model that served it, up
to 90% lower spend), with docs + blog links and a copyable curl for API users.
Persist the org/user routingEnabled preference server-side via the SAME sealed-
cookie store the credential store uses (extended with a non-secret settings
blob) behind a new static /ai-accounts/v1/settings route (GET/PUT, session-gated,
CSRF-guarded). The toggle is honest about scope: a preference Hanzo surfaces read;
API callers opt in per request with model:"auto".
- lib/server/ai-accounts.ts: AI_SETTINGS_COOKIE + normalizeSettings/readSettings/
settingsCookie (fail-closed to routing OFF)
- app/ai-accounts/v1/settings/route.ts: GET/PUT preference route
- lib/api/ai-accounts.ts: AiAccountsSettings + settings()/saveSettings()
- components/products/ai-accounts/RoutingTab.tsx: value-prop card + toggle + curl
- AIAccountsModule.tsx: third Routing tab; registry.tsx: routing subpage
Validate: tsc --noEmit clean; vitest 1925/1925 (+2 normalizeSettings).
Co-authored-by: Hanzo AI <ai@hanzo.ai>
build:embed (the hanzoai/cloud go:embed source) failed in any isolated clone:
@hanzo/usage was pinned to file:../usage/packages/core — a sibling repo absent
from the Docker console-stage clone — so it resolved to a dangling symlink and
`next build` died with "Cannot find module '@hanzo/usage'". This is why the
cloud image silently shipped the placeholder shell.
Pin @hanzo/usage to the published 0.1.0 (registry.npmjs.org; exports the
UsageSnapshot type the console imports). Also drop the accidentally-committed
`node_modules` symlink (-> /Users/z/work/hanzo/console2/node_modules, a dead
macOS dev path to the old repo name) and tighten .gitignore to a bare
`node_modules` so it can't recur.
Verified: npm install + npm run build:embed from this branch emit a real static
export (7.7M out/, ~360KB index.html + 4.4M _next/) that hanzoai/cloud embeds.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
New Observe module 'ai-accounts' (label AI Accounts) with Overview + Accounts
tabs, over the headless @hanzo/usage engine plus the org's Hanzo commerce lane.
- Registry: one CatalogEntry (kind module, routes ''+:tab, subpage 'accounts')
so /ai-accounts and /ai-accounts/accounts route with no new page files.
- Accounts tab: connect Hanzo (native), OpenAI/Codex, Anthropic/Claude by
pasting an API key / OAuth token / cookie header; non-AI groups are honest
catalog-driven 'coming soon' rows. Secret POSTed to the new server route.
- Overview tab: server route runs runPipeline (nodeHost) per connected provider
and merges the real commerce CloudUsageOverview for the Hanzo lane; empty
state has a 'Connect your AI accounts' CTA (1% routed-usage fee copy).
- Credentials: sealed (AES-256-GCM, reusing session.ts) in an httpOnly cookie
scoped to /ai-accounts, per user; never localStorage, never logged, masked on
read-back. TODO(KMS) to move at-rest storage to kms.hanzo.ai.
- Dep: @hanzo/usage file:../usage/packages/core; serverExternalPackages entry.
typecheck clean; vitest 1923/1923.
Every login now lands org-LESS on a full-page org picker (the "Home" org
list) instead of auto-scoping into the brand org. Clicking an org card scopes
the whole console to it (X-Org-Id); a sidebar Home affordance de-scopes back
to the picker. A one-org user still sees a one-card list and clicks in; a
global admin (z@hanzo.ai) sees every live org (masquerade).
- org-scope: add the SELECTION concern orthogonal to the org VALUE —
hasSelectedOrg()/enterOrg()/leaveOrg(). currentOrg()/setCurrentOrg()/
isScopedAway()/switchOrg()/filterOrgs() unchanged; switchOrg keeps the
selection set. Pure + tested (+4 tests).
- OrgPicker: the full-page landing — responsive card grid (logo or monogram,
role, honest quick facts), filter (reuses filterOrgs), client "Show more"
pagination (PAGE_SIZE 24), a Create-organization CTA (→ OrgOnboarding), and
honest loading/empty/no-match/error states (own-org fallback, never
fabricated). Global admin lists all orgs via /admin/iam; a tenant sees its
own org synthesized from the session.
- org-picker/logic: the pure decision core (sort/filter/paginate + the card
view-model, role, facts). 25 vitest cases, incl. a literal-substring
(no-regex) guard.
- OrgGate: routes 0 orgs → OrgOnboarding · has-orgs+none-selected → OrgPicker
· selected → the scoped shell. Drops the auto-scope-seed/reload effect
(explicit enter replaces it). Selection read on mount to avoid a flash.
- DashboardShell: the sidebar top-left keeps the OrgSwitcher (active org +
quick-switch) and gains a Home affordance (leaveOrg → picker) in the
expanded rail, the collapsed rail, and the mobile drawer.
- Drive-by (unblocks `next build` — pre-existing on this base): null-guard
useSearchParams()/usePathname() in app/accept + SearchModule (Next 15
stricter types).
typecheck clean (0), vitest 1923/1923 (+36 across the two new/updated files),
next build green (18/18). @hanzo/gui v5 shorthands only.
The level-1 product nav let multiple category sections stay expanded at once
(toggleCategory flipped each category independently), cluttering the sidebar.
Make it a true single-open accordion: opening a category collapses whatever was
open, and with no explicit choice the active route's category is the one open
section. Keeps the filtering=all-open and active-route-visible behaviors.
- nav-accordion.ts: openChoice() enforces the single-open invariant on read;
categoryIsOpen honors exactly one choice (else the active category);
toggleCategory opens only the clicked category (or clears on re-click).
Exported signatures unchanged — DashboardShell binding untouched.
- test: assert the single-open invariant (opening one collapses others; never
two expanded). 11/11 green.
/models regression — groupByFamily dropped every do-ai model (OpenAI, Claude,
DeepSeek, Llama, …) because their provider "do-ai" matched no curated family, so
only Zen showed. Families are now derived from the ONE brand resolver
(brandForModel, by model id), so every gateway vendor surfaces (Zen first, then
OpenAI/Anthropic/Google/Meta/DeepSeek/Qwen/…); an unknown vendor falls to an honest
"Other models" catch-all — a chat model is never silently dropped again. Adds
OpenAI o-series id resolution + curated GLM/MiniMax marks; embedding/video ids are
typed correctly so they stay out of the chat browser.
/auth/refresh 502 — tokenRequest retries once on a TRANSIENT upstream failure
(network error or a non-JSON/HTML body, e.g. IAM mid-roll on its Recreate strategy),
self-healing a momentary blip; a definitive OAuth error envelope is still not retried
(invalid_grant → 401, else 502). The /models page also renders independently of the
session (pricing/plans failures are already caught).
Overview cleanup — removed the redundant Billing/Usage/Metrics summary band from
every product overview (the dedicated Billing/Usage/Metrics pages own those figures);
deleted the now-dead ProductQuickLinks component + quick-links helpers.
Sidebar redesign (Google-Cloud-Console style) — the top switcher is the USER
(account menu: profile · theme · sign out) with the ORG switcher directly below it;
removed the org-accent green left strip; the two-level slide is replaced by a single
always-visible grouped nav where the active product's sub-pages expand INLINE, so any
product or sub-page routes directly with no "back". The org switcher moved out of the
topbar (project scope stays). Category overview gains prev/next paging through the
fixed category order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v8.4.114 rendered the upstream attribution only on NativeOverview products
(gateway) — resource cards and bespoke admin modules bypassed it. Add ONE
shared surface instead: `ProductUpstreamNote` mounted once in DashboardShell
under the product content column, resolving the active entry's `upstream` from
the catalog (the single source of truth). Now every fork module — native,
resource, or bespoke — shows "Built on open source — forked from <name>
(<license>)" linking upstream. Original Hanzo products render nothing.
Drop the now-redundant per-overview "Forked from" button in NativeOverview
(the shared note supersedes it — one link, one way); the informational
"Upstream" key-fact stays.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add an optional `upstream {name,url,license}` field to CatalogBase and set it
on the 12 verified fork modules (upstream verified against each repo's git
upstream remote / LICENSE / tree fingerprint):
base, records → PocketBase (MIT) iam → Casdoor (Apache-2.0)
chat → LibreChat (MIT) kv → Valkey (BSD-3-Clause)
docdb → FerretDB (Apache-2.0) s3 → SeaweedFS (Apache-2.0)
search → Meilisearch (MIT) vector → Qdrant (Apache-2.0)
studio → ComfyUI (GPL-3.0) auto → Activepieces (MIT)
gateway → KrakenD (Apache-2.0)
Render it in the module about surfaces (reusing existing plumbing, no new
components): NativeOverview gains a "Forked from <name>" link button beside
Source, resolveSpec appends an "Upstream" key-fact (name + SPDX license), and
the ProductInterstitial open-source card notes the fork. Original Hanzo
products (no upstream) are unchanged. Distinct from the Zen model brand policy —
this credits product forks, never model provenance.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds a customer-facing Finance surface (id finance-center, label Finance, Observe)
rendering the SHARED @hanzo/finance-ui FinanceDashboard — the SAME board
finance.hanzo.ai renders — over the unified finance ledger (/v1/finance/*), so a
spend/usage/credits card is identical across both surfaces (the shared-reuse point).
- finance-ledger.ts: console transport (the /cloud user-bearer proxy resolves the
org from the token owner; cookie-only bare /v1/finance/* 403s on the live ingress),
envelope-unwrap, wired to httpFinanceClient. +4 tests.
- FinanceModule.tsx: console chrome (PageHeader) + honest states (BackendStateCard)
around the shared board. Nothing reimplemented, nothing fabricated.
- next.config.mjs: 'finance' -> CLOUD_V1_HEADS (rewrite /v1/finance/* -> /cloud);
@hanzo/finance-ui -> transpilePackages.
- proxy-allow.ts: 'finance' -> CLOUD_HEADS (defense-in-depth allow-list).
- registry.tsx: catalog entry (distinct id from the admin 'finance' FinOps board and
from 'billing'/commerce).
Distinct from the shared package (@hanzo/finance-ui, published) which both this
module and finance.hanzo.ai consume. tsc clean; vitest 1891/1891; next build green.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
State-of-the-art visual/interaction QA pass (as Dave/maxpower, live) found the
console already excellent across mobile/tablet/laptop/desktop — distinct per-family
model icons, honest GPU prepay-card vs Machines credit gate, working ⌘K palette,
org switcher, quick-links band navigation, light+dark contrast, and NO horizontal
body overflow at any viewport. Four small CSS/markup polish defects were fixed:
- **Touch tap targets < 44px.** The top-bar controls (hamburger/Apps/account at
size="$3" = 36px; ThemeToggle/Help/Notifications at size="$2" = ~28px) were under
the 44×44 minimum on a coarse pointer. Added a `hz-topbar` class on the top-bar
XStack (DashboardShell) + a `@media (pointer: coarse){ .hz-topbar button {min-height/
min-width:44px} }` rule — touch devices meet WCAG 2.5.5; desktop mouse density is
deliberately unchanged (the rule is coarse-pointer-scoped).
- **No global keyboard focus ring.** Added a `:focus-visible` outline floor
(theme-token colour, adapts light/dark) + `:focus:not(:focus-visible){outline:none}`
so every control is focus-visible on tab-through without touching pointer presses.
- **Horizontal-scroll guard.** `html, body { overflow-x: clip }` — a stray fixed/
off-screen drawer can never scroll the whole document sideways (`clip` keeps sticky/
fixed descendants working). No overflow was observed live; this is the permanent floor.
- **Floating chat FAB overlapped bottom-right content** (Live/Catalog pills, table
badges). Reserved an 80px bottom gutter on the content column (split the content
`py` into responsive `pt` + fixed `pb={80}`) so the last row always clears the FAB.
e2e: e2e/polish-qa.spec.ts locks all of it — a PUBLIC block (runs in CI, no creds)
for the overflow-x guard + :focus-visible ring + no-sideways-scroll at 390/768/1440,
and an authenticated block (gates on HANZO_PASSWORD, repo convention) for overview
real-data, quick-links navigation, the GPU prepay-card gate, distinct model icons,
mobile sidebar→drawer collapse, and the ≥44px touch tap-targets.
Verification: tsc --noEmit clean; next build ✓ (BUILD_EXIT:0). Rebased on origin/main
(v8.4.111) → v8.4.112. Live re-verify + the authenticated e2e block are the post-deploy
gate (the (dashboard) group is behind AuthGate).
Co-authored-by: Claude <noreply@anthropic.com>
Mirrors the Affiliates + Referrals pattern for the new OSS Author program over the
real cloud /v1/authors surface (native-Go clients/authors). An author connects GitHub,
proves they own a repo (GitHub OAuth admin-check OR a hanzo.json verify-code file), and
earns a royalty when any org deploys their open-source project on Hanzo.
- AuthorsModule (customer, id:authors, Web3): not-enrolled Connect-GitHub card + 3-step
explainer; enrolled dashboard — status/verified/share, 4 MetricCards, a repositories
panel (verify + per-repo Copy-badge markdown), a verify-by-file recipe (hanzo.json
snippet), deploys-of-your-work, and payout history. Honest empty states throughout.
- AuthorsAdminModule (id:authors-admin, admin:true, Observe): Run-sweep + summary tiles +
author directory with status-gated Approve(+share override)/Reactivate/Payout/Suspend;
server-gated via /admin/aggregate; honest access/empty/error states.
- Clients: lib/api/authors.ts (BARE JSON via cloudProxyV1Url) + lib/api/admin-authors.ts
({status,msg,data} envelope via originGet/originPost); pure products/authors/logic.ts.
- Registration: 2 registry.tsx catalog entries (BookOpen icon); 'authors' added to
next.config.mjs CLOUD_V1_HEADS + ADMIN_V1_HEADS, proxy-allow.ts CLOUD_HEADS, and
admin-aggregate.ts ADMIN_AGGREGATE_HEADS. No claim.ts/session wiring (connect/verify
based — no ?xxx= link) and no app/ route (the catch-all resolves it).
Gates green: tsc --noEmit 0 errors; vitest 1864 → 1887 (+23: 7 authors + 8 admin + 8 logic);
next build ✓ (Compiled successfully, 18/18 pages).
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Mirrors the Affiliates + Referrals pattern for the new OSS Author program over the
real cloud /v1/authors surface (native-Go clients/authors). An author connects GitHub,
proves they own a repo (GitHub OAuth admin-check OR a hanzo.json verify-code file), and
earns a royalty when any org deploys their open-source project on Hanzo.
- AuthorsModule (customer, id:authors, Web3): not-enrolled Connect-GitHub card + 3-step
explainer; enrolled dashboard — status/verified/share, 4 MetricCards, a repositories
panel (verify + per-repo Copy-badge markdown), a verify-by-file recipe (hanzo.json
snippet), deploys-of-your-work, and payout history. Honest empty states throughout.
- AuthorsAdminModule (id:authors-admin, admin:true, Observe): Run-sweep + summary tiles +
author directory with status-gated Approve(+share override)/Reactivate/Payout/Suspend;
server-gated via /admin/aggregate; honest access/empty/error states.
- Clients: lib/api/authors.ts (BARE JSON via cloudProxyV1Url) + lib/api/admin-authors.ts
({status,msg,data} envelope via originGet/originPost); pure products/authors/logic.ts.
- Registration: 2 registry.tsx catalog entries (BookOpen icon); 'authors' added to
next.config.mjs CLOUD_V1_HEADS + ADMIN_V1_HEADS, proxy-allow.ts CLOUD_HEADS, and
admin-aggregate.ts ADMIN_AGGREGATE_HEADS. No claim.ts/session wiring (connect/verify
based — no ?xxx= link) and no app/ route (the catch-all resolves it).
Gates green: tsc --noEmit 0 errors; vitest 1864 → 1887 (+23: 7 authors + 8 admin + 8 logic);
next build ✓ (Compiled successfully, 18/18 pages).
Pixel QA at 360/390 phone widths surfaced three mobile defects, all now fixed
in the shared primitives (verified in a real browser at 390 + 834):
- PageHeader: a header with many actions (Tracker's Projects/Refresh/List/Board/
New issue/Delete) ran off-screen and CLIPPED the last buttons — "New issue" and
"Delete project" were unreachable on a phone. Actions now take a full line below
the title and WRAP (< $md); inline right-aligned at $md+. TrackerModule's action
row is full-width on phones so it wraps rather than overflows.
- Field (SlideOver create/edit form): the fixed 180px label + 240px control forced
the row wider than a phone, clipping every label. FieldRow now STACKS (label above
a full-width control) below $md and keeps the two-column layout at $md+.
- DataTable empty state: the "Nothing here yet." message sat inside the min-width
(horizontally scrolling) table area and was clipped off the right on a phone. It
now renders outside the scroll area and centers/wraps within the visible width.
No change at tablet/laptop/desktop (>= $md) — the two-column form and inline actions
are preserved. typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the placeholder referrals entry (a ConsoleFeatureModule shim that 404'd —
no backend existed) into a real product over the new cloud /v1/referrals surface.
- src/lib/api/referrals.ts — customer client over the /cloud user-bearer proxy
(cloudProxyV1Url, live-ingress-safe for a new head): overview + claim, defensive
normalizers.
- ReferralsModule — one clean screen: link + Copy, 'give $5 get $10' explainer,
three real stat tiles (invites/credited/credit earned), referrals list with live
status; loading/BackendStateCard/empty states, no fabricated rows.
- Admin board (admin: true, hidden from customers): src/lib/api/admin-referrals.ts
via originGet/originPost -> the global-admin-gated app/admin/aggregate proxy;
ReferralsAdminModule = summary tiles + directory + a 'Run sweep' action.
- Signup capture: src/lib/referrals/claim.ts (stashReferralCode reads ?ref into
localStorage; claimReferralOnce POSTs /v1/referrals/claim once per session per
org after first login), wired into session.tsx beside the welcome grant.
- Wiring: 'referrals' added to CLOUD_V1_HEADS + CLOUD_HEADS (customer proxy) and
ADMIN_V1_HEADS + ADMIN_AGGREGATE_HEADS (admin proxy). Registry repointed to the
real module + an admin entry; dropped the dead docs deep link.
Gates: tsc --noEmit clean; vitest 1838/1838 (+15 referrals api/logic/claim); next
build ok. Version 8.4.107 -> 8.4.108.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The /plans "Upgrade to Pro" page showed a bare "Not authorized": PlansApi.pricing() hit GET /v1/pricing, but on the live console ingress /v1/* is routed straight to the gateway-fronted cloud binary (cookie-only, no bearer) and 401s, so no tiers rendered and nobody could subscribe to Pro.
Fix: read the money-truth catalog through the per-tenant billing proxy (GET /billing/v1/plans -> commerce api/billing.ListPlans), the SAME credentialed BFF path every working billing call uses (balance/usage/subscriptions). Map the commerce staticPlan wire shape (bare array, CENTS, slug) to a display Plan (whole dollars), filter to the cloud account tiers (personal/team/enterprise), order the grid, and mark Pro popular. Each card CTA opens the brand billing portal checkout (config.billingUrl#pricing, Square) — a live, non-dead subscribe path matching the portal own CTA.
plans.ts rewritten (endpoint + shape + Plan/PlanLimits types); PlansModule.tsx consumes the new shape with honest loading/empty/error (BackendStateCard); index.ts exports Plan/PlanLimits; plans.test.ts (6) pins endpoint + cents->dollars + Pro popular + filter + order; canonical-paths.test.ts pins PlansApi.plans -> /billing/v1/plans.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The Models page marked zen5-mini as the "Default" model, but zen5-mini is
PREMIUM — it 402s for a trial / $5-welcome balance, so a new user's first
call to the "default" model fails. The user-facing default must be a model
that always works on the trial tier.
zen5-flash is the non-premium Zen flagship (per the live catalog `premium`
flag; the Playground's default-model.test.ts documents the ground truth:
"zen5 / zen5-mini / zen5-max premium; zen5-flash / zen5-coder not").
- families.ts: DEFAULT_MODEL 'zen5-mini' -> 'zen5-flash' (the ONE constant
the Models page "Default" pill and the Zen-family sort read).
- ChatConversation.tsx: preselect DEFAULT_MODEL (case-insensitive) before
falling back to first-Zen, so Chat agrees with the Models page and never
seeds a premium default for a trial user.
- families.test.ts: retitle + pin DEFAULT_MODEL === 'zen5-flash'.
Playground already picked the non-premium Zen flagship at runtime
(defaultModelId), so Chat, Playground, and the Models page now all agree on
the same trial-safe default. tsc clean; vitest 1808/1808.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Pre-launch QA sweep found two honest-state defects on the LIVE console.
1) Chat "(empty response)" — the gateway can answer a 200 whose BODY is a plain
casibase error envelope (NOT SSE): `{status:"error", msg:"the token count:
[4198] exceeds the model: [minimax-m2.5]'s maximum token count: [4096]"}`.
That body carries no `data:` events, so `readChatStream` yielded no content and
resolved to '' -> the chat rendered a silent "(empty response)" bubble that hid
the real reason. New pure `streamErrorMessage` (stream.ts) detects a non-SSE
JSON error envelope (casibase `{status,msg}` AND OpenAI `{error:{message}}`),
and `readChatStream` throws it when the stream produced no content -> honest
error card. A real completion object (`choices`) is never treated as an error,
and a genuinely-empty successful stream still resolves to '' (no false error).
(The premium-model 402 path was already handled by the non-ok branch.)
2) Grants "$0.00" banner — the `POST /v1/admin/grants` create response does not
echo the requested amount/source, so the success banner read "Granted $0.00"
even though the ledger recorded the real amount. `AdminGrantsApi.create` now
backfills the REQUESTED amount/source/org when the response omits them, so the
returned row (which the banner renders) is self-consistent whatever the shape.
Tests: +stream.test.ts (6), +admin-grants.test.ts (3), +4 ai.test.ts regression
cases (200-envelope casibase + OpenAI, blank-stream no-false-error).
Gates: tsc --noEmit clean; vitest 1808/1808; next build OK.
NOTE (separate backend release): the ROOT cause of the chat failure is a GATEWAY
model-config bug — every routed model reports max_tokens=4096 (zen-agent->
minimax-m2.5, zen5->deepseek-v4-pro), far below their real context windows, so the
console's grounded system prompt (~4190 tok) exceeds it. This console fix makes the
error HONEST; the gateway 4096 cap must be raised on api.hanzo.ai.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The bearer authenticated but Casbin denied /mfa/setup/* — IAM's authz filter
derives the request object by JSON-parsing the POST body, so a form-encoded body
yields an empty object and the self-access grant (objOwner==subOwner) never
matched. Fix: send the MFA params as the QUERY STRING with an empty body and
always include the pinned owner/name, so the filter reads them (its len(body)==0
branch) and grants self-access — the same rule that lets get-users?owner=<me>
through. No IAM change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MFA self-enrollment was unreachable: the console delegated 2FA to hanzo.id's
account page, but the custom hanzo.id login worker never establishes a Casdoor
account session, so a worker-authenticated user hits 'Unauthorized operation' on
the MFA setup endpoints (verified live). Fix: enroll 2FA IN the console.
- app/console/mfa/[action] BFF forwards initiate/verify/enable/disable to IAM as
the caller's OWN user bearer (adminBearer(resolveUser)), owner/name PINNED
server-side — a user can only manage their own 2FA. IAM's authz filter accepts
the bearer and Casbin authorizes self-service MFA.
- lib/api/mfa.ts client + ProfileModule Security tab: real 'Set up authenticator
app' flow (initiate → show TOTP secret/otpauth URI → enter 6-digit code →
verify → enable), an On/Off state, and Turn-off. Password change still deep-links
to IAM. MFA is enforced at login already (checkMfaEnable→NextMfa; SignInForm
hands off to the hosted challenge).
tsc + 1785 vitest green; next build ✓ (/console/mfa/[action] registered).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live verification caught it: /console/accept ran update-user and it updated
displayName + signupApplication, but the password stayed EMPTY. Root cause:
casibase update-user with no `columns` param uses a default column set that
EXCLUDES password, and it only auto-appends the password columns when `columns`
is non-empty — so UpdateUserPassword hashed the value in memory but never wrote
the column (credential silently stayed unset). Fix: activateMember pins explicit
`columns=password,password_salt,password_type[,display_name,signup_application]`
so the hashed password + salt + type are persisted. Also mask the accept-page
password field (secureTextEntry+type=password, the @hanzo/gui workaround).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Team invite created a passwordless zombie member with NO way to sign in
(email/OTP delivery is unwired; IAM send-invitation is a stub). This closes the
loop with a console-native, no-email, no-new-IAM-capability accept flow:
- POST /console/invite-link mints a sealed (AES-256-GCM, HKDF of the confidential
client secret), 14-day, org-admin-gated accept token for a pending member.
- Public /accept page + GET/POST /console/accept lets the invitee set their OWN
password (IAM hashes via update-user's passwordChanged path — never plaintext),
then sign in and land in their org with the assigned role. Single-use for
activation (refused once the member has a credential).
- InviteDialog surfaces the shareable link (honest 'email delivery isn't wired'
copy); roster shows Pending vs Active + a 2FA badge, and a 'Copy invite link'
row action for pending members (activates the pre-existing zombies too).
- identity.ts gains getMember/memberHasPassword/activateMember (same confidential
client as createUser/moveUserToOrg). +8 invite-token tests. tsc + 1785 vitest
green; next build ✓ (/accept, /console/accept, /console/invite-link registered).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(onboarding): grant $5 welcome credit on signup + self-heal on load
FIX 1 (onboarding paywall): a new signup lands at a $0 balance and 402s on
first chat because the idempotent commerce welcome-grant is never invoked.
- identity.ts: new server-only grantWelcomeCredit(org,user) — mints a user-bound
bearer and POSTs the idempotent `/v1/billing/me/welcome` behind the gateway
(X-Org-Id scoped). Best-effort, never throws, never blocks signup.
- signup route: award the grant after createUser (idempotent; failure is harmless).
- BillingApi.welcome() + claimWelcomeGrantOnce(owner): self-heal on first
authenticated load (social-login + pre-existing $0 users), once per browser
session via the per-tenant /billing/v1/me/welcome proxy.
- wired into SessionProvider.applyAccount; unit tests for the guard.
* fix(playground): default to a NON-PREMIUM chat model so a $5 trial user gets 200
FIX 3: the auto-default preferred the bare Zen flagship (zen5), which is PREMIUM —
a cold, trial-funded user 402d ('premium model requiring a paid balance') on the
very first Run.
- thread `premium` through the catalog: RichModel.premium → CatalogEntry (spread)
→ ModelOption.premium (useModels normalizer, from the /v1/models `premium` bool).
- default-model: choose from the NON-PREMIUM pool first (fallback to full only when
every model is premium), and prefer the general-purpose tier (zen5-flash) over a
specialized one (zen5-coder) via a generalist tiebreak.
- tests prove the default is provably non-premium (skips zen5/zen5-max/featured-premium).
* fix(onboarding): signup grant via commerce service-token grant-starter (not user token)
A fresh personal-org user cannot be resolved by the confidential hanzo-console
client (it lives in org 'hanzo'), so issue-user-token/password-grant fails. Use the
DESIGNED trusted-service path instead: commerce POST /v1/billing/grant-starter with
the COMMERCE SERVICE TOKEN the console already holds (billing-proxy).
- billing-proxy: export commerceBaseUrl() + commerceServiceToken() (DRY, one address).
- billing-grant.ts: grantWelcomeCredit(orgSlug) POSTs grant-starter (subject == the
personal-org slug, X-Org-Id + user body, trigger tag for idempotent dedupe).
Best-effort, swallows failures. identity.ts reverted (no cross-org token mint).
- signup route calls it after createUser; unit tests cover posting + swallow.
* feat(admin): per-row Grant quick action + Trial/Prepaid source selector
FIX 2: staff grant-credit gets a per-ROW quick action and a source bucket.
- admin-cockpit grantCredit accepts source?: 'trial'|'prepaid' (GrantSource type).
- CustomersModule: extract ONE reusable GrantCreditPanel (amount + source toggle,
default Trial since comps are non-cash + reason) used by BOTH the detail view and
a new per-row 'Grant' action (stopPropagation so it doesn't open the detail).
Notice banner shows the granted bucket.
* feat(admin): Grants ledger + Projects boards (global-admin, admin.hanzo.ai)
DELIVERABLE 4 — two new admin-only catalog entries under Observe (admin:true,
hidden from customers), honest loading/empty/403 states, no fabricated rows.
- Grants (id fleet-grants): fleet credit-grant ledger + issuance. admin-grants.ts
routes GET/POST /v1/admin/grants through the /admin/aggregate BFF (grants head added
to ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS, following the compute pattern).
Table (org, amount, Trial/Prepaid source badge, reason, staff actor, date) + a New
grant form (org + amount + source toggle + reason → POST), refresh after issue.
- Projects (id fleet-projects): READ-ONLY cross-org deploy board. admin-projects.ts is a
pure lens over the EXISTING global PlatformApi.apps() inventory (per neo — NO
/v1/admin/projects endpoint): org, app, health, cluster, live URL, drift; drill by org
via a filter. groupByOrg + toProjectRow unit-tested.
- registry: both entries (aliased FleetGrantsModule/FleetProjectsModule to avoid the
customer 'projects' collision).
* feat(billing): show trial + prepaid balance buckets distinctly
DELIVERABLE 5 — surface the new commerce bucket split ($5 non-cash trial vs real
prepaid money) everywhere the org balance shows, reusing the ONE live-balance source.
- wallet.ts CloudBalance: add optional trialGranted/trialBalance/creditsGranted/
creditsRemaining/prepaidBalance/prepaidAvailable (legacy build omits them → degrade
to the combined total, never fabricated).
- live-balance.ts: trialCents/prepaidCents + balanceSplitLabel ('$5.00 trial + $X.XX
credits'), null when neither bucket is reported. Unit-tested.
- SidebarWallet / WalletModule / BillingCredits render the split under the total.
* release(console): v8.4.102 — onboarding welcome-grant + admin Grants/Projects + trial/prepaid balance
---------
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Make the org's compute fleet easy to SEE and USE in one place, and open cluster
self-service to paying customers (drop the admin gate). Per-org customer console;
admin.hanzo.ai's cross-org compute boards stay separate.
- Kubernetes module is now the UNIFIED FLEET cockpit: managed + attached BYO
clusters (GET /v1/clusters, MERGED, with kind + nvidia/amd GPU inventory + node
count + status) and dialed-in BYO machines (GET /v1/machines, provider="byo"),
in one honest view — loading / empty "no compute yet" / error BackendStateCard,
never a fabricated row. Supersedes the old capacity-card design.
- Enable BYO cluster attach (replaces the dropped "import cluster" stub): a Register-
cluster form (name + kubeconfig paste/upload + default toggle) -> POST /v1/clusters,
with honest error paths (503 KMS-not-configured, 422 unreachable-kubeconfig,
402 billing, 400 invalid). PlatformApi.attachCluster/detachCluster added; the
Cluster type gains nvidiaGpu/amdGpu (matching the cloud clusterView).
- Show the three connect options so it is easy to DO: BYO cluster (the attach form),
BYO box (copy-paste `hanzo gpu connect` + desktop auto-link note), and BYOC cloud
account (honest "connect your AWS/GCP/Azure/DO account - coming").
- Un-hide the `clusters` + `kubernetes` catalog entries (drop admin:true) so a paying
customer reaches cluster self-service from the unified console.
- visor.ts VisorMachine gains os (provider already present) so BYO boxes are labeled.
- Pure kubernetes/logic.ts (summarizeFleet / byoBoxes / clusterNodeTotal /
describeAttachError / CONNECT_SNIPPET) + 16 vitest tests.
Verify: npm run typecheck (0 errors), npm test (1752 pass), next build (green, 15/15).
Add a first-class "Git" catalog entry linking to the self-hosted Gitea
code host at git.hanzo.ai, following the SAME external-launch pattern as
Automation (auto.hanzo.ai): kind:'external' + href, brands:['hanzo'] so
the URL never leaks onto a Lux/Zoo console. One CatalogEntry surfaces it
in the Dev nav, the catalog overview, the app launcher, ⌘K, favorites,
and the discover interstitial — no shell/route edits (the registry is the
single source of nav + routing truth). GitBranch icon (already imported);
no docs/repo field (git.hanzo.ai IS the code host, no docs.hanzo.ai page).
tsc --noEmit clean; vitest 1692/1692; next build ✓.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Org-brand sidebar header + Hanzo-H fallback, Material elevation/paper tokens,
alphabetical + selected-first product order (order.ts, DRY + tested), mobile nav
drawer opens LEFT and auto-closes on product tap, opacity-only hz-menu-in for
floating-ui anchored menus (SelectMenu/ComboBox) so they stay anchored.
@hanzo/gui shorthands only; no Svelte/Radix.
Live mobile audit of console.hanzo.ai (v8.4.98) at 375/768/1440. Stacking is
solid (zero horizontal overflow anywhere) and all core mobile interactions work
(hamburger nav drawer, ⌘K palette, search, org/scope switchers, app launcher,
chat composer). Two clear defects fixed:
1. Topbar search truncated to "S…" on phones. Two flex:1 siblings — the search
box and the right-side spacer — halved the box's width below lg, and the ⌘K
chip (meaningless without a keyboard) ate the rest, so the placeholder was
unreadable. The spacer now only flexes at lg+ (below lg the box fills the row)
and the ⌘K hint is hidden below lg. Desktop layout is unchanged.
2. FloatingChat bubble overlapped the composer's send control on /chat and
/playground (it's fixed bottom-right on every page). It is now suppressed on
those two surfaces — which already have their own full composer — while
staying one tap away everywhere else and still openable programmatically.
Gates: tsc --noEmit clean · vitest 1664/1664 · next build ✓.
The console home now shows the org's full resource picture in one place: top-line
stat tiles (apps running, GPUs online split cloud vs BYO, nodes) plus three
at-a-glance sections (Apps, GPUs, Nodes) that deep-link to the product owning each.
Read-only aggregation over three REAL per-org sources — apps (/v1/platform), GPUs
(/v1/gpus), machines (/v1/machines) — loaded independently, so a slow/denied/unrouted
source degrades to its own "not reporting" line and never blanks the board.
- normalizeMachine (visor) + normalizeGpu (compute) now pass `provider` through so a
bring-your-own node/GPU (a DGX Spark GB10) badges BYO distinctly from Hanzo Cloud;
normalizeGpu also tolerates a BYO GPU's string `memory` ("128GB"/"80 GiB"/"131072MB")
→ numeric VRAM. Additive, defensive; DOKS rows unchanged.
- ResourceOverview + pure resource-logic (provider kind, online cloud/BYO split), both
unit-tested; PlatformAppsApi.listAllApps() de-dups the org-wide app read (DRY).
Full BYO render lands with the cloud redeploy that unions Visor DOKS inventory with
BYO fleet workers into /v1/machines + /v1/gpus.
floating-ui positions SelectMenu/ComboBox Popover.Content with an inline
transform; a transform-based entrance (hz-pop-in) overrides that inline value
for the animation duration and detaches the menu from its trigger. New
opacity-only hz-menu-in keeps the anchor exact; hz-pop-in stays for centered
Dialog surfaces. Reduced-motion snaps.
Left-side drawer matches the top-left hamburger (account drawer stays right);
SidebarNav.openProduct calls onNavigate() so the off-canvas nav dismisses on a
product tap (desktop passes a no-op — stays open).
Monochrome node cards (@hanzo/gui tokens) with kind icon + live status dot
(running pulses; reduced-motion disables it), pan/zoom + fitView, minimap, controls.
One org-scoped read (PaasApi apps + ProvisioningApi data); polls via usePoll; honest
loading/empty/error states. Click a node -> side panel with details + deep links to
the product page and its /metrics observability. Canvas is client-only (dynamic
ssr:false); colorMode + edge hues track the real app theme.
Honest edges only: domain->app from app.domains; app->resource only where an
unmasked env value names the resource host/name (secrets are masked, so a drawn
edge is a real link, never invented). Deterministic 3-tier layered layout. 18 unit
tests cover status normalization, node/edge derivation, layout determinism, summary.
Close out the "port remaining old-console views" task. The old console
(hanzoai/console) and console2 have fully converged: /home/z/work/hanzo/console
is byte-identical to origin/main (v8.4.97), so nothing in "old" is missing from
"new". The only components not reachable from the product registry were an early
wave of views, since superseded and never re-wired:
- iam/{UserEditView,AppEditView,OrgView,logic} (from wip savepoint f28d796)
-> superseded by AdminModule's IamModule (Orgs/Users/Roles, full CRUD) +
AuditModule, and per-org IAM apps managed live in tenants/TenantDetail.
- applications/{ApplicationListView,ApplicationEditView,logic} (from cf5d309)
-> superseded by ApplicationsModule -> PaasApplications (the real deployed-apps
surface over /v1/platform).
- lib/api/applications.ts (ApplicationApi) + the `Application` type
-> the legacy casibase-era /v1/*-application(s) client, its only consumers were
the deleted views; the modern paas.ts client is the one and only way now.
One way, no dead code. Gates: tsc 0 errors, vitest 130 files / 1664 tests green,
next build compiled.
RED reviewed the v8.4-billing-surface proxy (src/lib/server/billing-proxy.ts).
All five findings fixed by REUSING the already-hardened bearer-proxy.ts pattern
(pathIsClean + normalized-URL re-validation + streaming), not a new guard.
RED-1 [SHIP-BLOCKER] encoded path-traversal out of /v1/billing/. isSafeSegment
rejected literal `..`/`/` but not `%2e%2e`/`.%2e`/`%2E%2E`/double-encoded
`%252e%252e` (Next single-decodes -> `%2e%2e`), which undici normalizes to a
real `..` and pops out of /v1/billing/ to the whole commerce API with the
service Bearer. FIX: replace isSafeSegment with the shared pathIsClean
(rejects empty, `.`/`..`, ANY `%XX`, matrix-param `;`) on the raw path, AND
re-validate the NORMALIZED URL.pathname still begins with /v1/billing/ AFTER
undici resolves it. Either check fails -> 400 with NO upstream fetch. Applies
to GET/POST/DELETE (the check is before the body read + fetch).
RED-2 [MED] content-type confusion / missing nosniff. Upstream CT/Content-
Disposition were forwarded verbatim, no nosniff. FIX: set
X-Content-Type-Options: nosniff on EVERY billing response; the binary branch
FORCES Content-Disposition: attachment with a SANITIZED filename (CR/LF/`;`/
quote/path chars stripped, never the upstream's verbatim); an active textual
type (text/html/xhtml/svg/js from a compromised/MITM'd plaintext hop) is
served inert as text/plain so window.open can never execute it at our origin.
RED-3 [MED] unbounded buffering -> memory DoS. res.arrayBuffer()/res.text()
buffered the whole body. FIX: STREAM res.body through on BOTH branches (matches
bearer-proxy); a null-body status (204 detach) still carries null.
RED-4 [LOW] 502 leaked the raw upstream exception (internal host). FIX: generic
"Billing upstream is unavailable." to the client; console.error the detail
server-side (mirrors bearer-proxy).
RED-5 [LOW] regression-net gap. New tests: encoded-traversal `['%2e%2e',...]`,
`['.%2e',...]`, `['%2E%2E',...]`, double-encoded, `%2f`, `..;`, and any
`%`-containing segment -> 400 with NO fetch (all verbs); binary-branch PDF
carries nosniff + attachment; nosniff on JSON; text/html+svg served inert;
generic 502 with no leak; pure helpers.
CONFIRMED-SAFE properties untouched: CSRF-first ordering, resolveUser/401,
COMMERCE_TOKEN/501, scopedBillingSearch/Body subject pinning, X-Org-Id from the
validated session.
tsc --noEmit: 0 errors. vitest: billing-proxy 28/28 (was 11), billing suites
114/114, full 1698/1698.
Complete the Billing Center's three external-portal punts over the ONE per-tenant
/billing/v1/* commerce proxy — PCI posture unchanged, tenant scoping server-side.
- Payment Methods: in-console Add (Square iframe -> nonce -> POST payment-methods;
RAW PAN never leaves the browser) + per-row Remove (confirm -> DELETE
payment-methods/:id). Set-default fail-secure-skipped (customer-id-in-path not
proxy-scopable).
- Subscriptions: in-console Cancel (at-period-end vs now -> POST :id/cancel) +
Reactivate (POST :id/reactivate); row reflects cancelAtPeriodEnd/canceledAt.
- Invoices: Download builds the PDF URL from the invoice id via the same-origin
proxy. Proxy extracted to tested lib/server/billing-proxy.ts: adds DELETE +
binary passthrough (application/pdf streamed as raw bytes with Content-Type/
Content-Disposition, no text() mangling), all auth/CSRF/scoping intact.
- DRY: useSquareCard hook shared by BillingCredits + PaymentMethodsModule.
- New BillingApi: createPaymentMethod/removePaymentMethod/cancelSubscription/
reactivateSubscription. tsc clean; vitest 1689/1689 (+22 billing/proxy tests).
Two live capstone defects when billing/balance|usage 502s during a deploy roll:
1) Billing tiles hung on Loading forever (Projected had no error branch; no load timeout). 2) balance was refetched hundreds of times (636 in one session) with no backoff.
live-balance poll: fixed 30s setInterval -> self-scheduling timer with exponential backoff (base 2s, doubling, cap 60s) + equal-jitter, gated so automatic mount/focus/poll wait out the window; resets to 30s on first success; a user Refresh / balance-affecting action bypasses the breaker. Bounded call count during an outage instead of a storm. Public API + snapshot shape preserved.
Billing tiles: pure tileView() + useTimedOut() bound the spinner — on fetch error OR after a ~10s timeout the Cloud credit / Spend / Projected / Daily-spend tiles render the honest -/Unavailable fallback (the Dashboard/Machines pattern), never an infinite spinner. Happy path unchanged.
Author: Hanzo Dev <dev@hanzo.ai>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Defense-in-depth on the Status-board endpoint redaction: also drop hosts
on internal TLDs (.internal/.intranet/.corp/.lan/.home/.cluster, and any
*.cluster.* path) in addition to .svc/loopback/RFC1918/IPv6. The audit's
real leak (*.svc:port, localhost, 127.0.0.1) was already covered; this
closes the theoretical case of a scrape label on a private DNS zone. One
extra alternation + test cases. Version → 8.4.96.
Harden the casibase store-admin allow-list added in v8.4.94: admit ONLY
the heads the console actually calls — get-stores/get-store (read),
add-store/update-store/delete-store/refresh-store-vectors (mutate).
Drop get-global-stores (a cross-tenant read the console never invokes —
StoreApi.listGlobal has zero callers) and get-store-names (unused) from
CLOUD_HEADS, so the /cloud proxy is not a wider tunnel than needed. Org
is always the Bearer owner server-side, but not exposing an unused
cross-tenant-read head is defense in depth. Pinned with a proxy-allow
test (positive + the two least-privilege negatives). Version → 8.4.95.
Make every console page real: no "coming soon", no dead/disabled stub
buttons, no false "not enabled"/"session expired". Ship live.
Zero coming-soon guarantee (structural):
- Remove ProductStatus 'soon' entirely (0/148 catalog entries used it) →
type now forbids a coming-soon product. Drops the SOON badge, Preview
label, and waitlist path from DashboardShell / AppLauncher /
CategoryOverview; delete the orphaned WaitlistForm (zero importers).
- Providers: remove the BYO-Weights tab (redundant with Custom Models,
only carrier of a future promise) + drop the soon badge from HonestTab.
- Wallet: HUSD "coming soon" → honest present-state + a real "Add credit
on Billing" (Square) action.
- GPUs: remove every dead `disabled hint=` stub (Add GPUs / Import
cluster / Connect DO/AWS / per-row kebab / Run diagnostics); the real
path is "Connect a provider" → /secrets (KMS credentials). KEEP the
real Prev/Next pagination (a boundary-disabled control, not a stub).
- Reword to present-state: gpus ledger caption, ProductSubpageStub,
platform/state 'unavailable', tasks/detail Logs/Signals/Queries,
OrgIntegrations (dead branch removed), hanzo-evm HUSD error strings.
- Finetuning: KEEP the registry entry (its module is already wired to the
LIVE /v1/train/* via TrainApi), DELETE the orphaned dead finetuning/
subdir + api/finetune.ts (hit the 404 /v1/finetune/*, zero importers).
Audit routing fixes (same class as the Vector /cloud fix — the live
ingress does NOT run next.config /v1/* rewrites, so bare-/v1/ clients
that rely on them fail; address the proxy EXPLICITLY):
- Commerce store clients → /commerce proxy (commerceProxyV1Url) instead
of rewrite-dependent /v1/commerce/* → fixes the 6 store pages'
FALSE "Not enabled for your account".
- Embeddings stores + ingest + cloud-usage ledger → /cloud bearer proxy
(new cloudGet/cloudPost) + allow-list the casibase heads (get-stores,
get-store, …, docs, get-files, get-cloud-usages) in proxy-allow →
fixes the FALSE "session expired" 401 on Collections.
- Status board: redact internal scrape instance (host:port) at the
source (redactInstance) so visor.hanzo.svc:19000 / localhost:8428 /
127.0.0.1:8429 never render to a customer.
Tests: tsc clean; vitest 1636 green (new redactInstance suite + a
match-core anti-drift guard that a declared subpage must be routed or it
stubs; canonical-paths pins commerce→/commerce, stores→/cloud); next
build green. package.json 8.4.89 → 8.4.90.
Two changes on this branch, both green (tsc clean, 1645 vitest pass, next build success):
1. feat: native Automations product tile — adds the `automations` catalog entry
pointing at the native /v1/automations engine (HIP-0106, repo hanzoai/cloud,
700+ connectors), brand-scoped to hanzo. Backend (cloud#113) already merged;
surface live at auto.hanzo.ai/automations (verified 200). Distinct from the
existing standalone `auto` tile (repo hanzoai/auto). Follow-up: consider
relabeling to disambiguate the two automation tiles in the grid.
2. fix(auth): admin-host silent SSO for the admin-console client — on admin.<brand>
the signin page now auto-initiates admin-console PKCE OAuth against the live
guard SSO session (no second manual login resolving the wrong identity).
Loop-guarded; tenant hosts (e.g. console.hanzo.ai) UNCHANGED — still SignInForm.
Header-scoped cloud heads (gpus, clusters, functions, platform/paas, vpcs,
load-balancers, builds, releases, pipelines, environments, indexers, oracles,
authz, embeddings search) 403 on the live ingress via bare /v1 (gateway strips
X-Org-Id, no minted bearer) — verified live. Route them through the /cloud
user-bearer proxy (cloudProxyV1Url), same class-fix as framework/s3/machines
(v8.4.70). Turns ~15 silently-broken modules into real per-org data.
Also: inline docs links -> canonical /docs/<slug> (edge/storage/machines/gpus/
agents/inference->gateway/crawl/functions/kms; embeddings landing kit); ChatView
carries the real chat owner (2-seg /chat/:owner/:name) instead of hardcoded
'admin' (non-admin orgs could not open saved chats); SearchModule derives its
base path so Crawl tabs/CTA stay under /crawl.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve a model's brand from its IDENTITY (id/name) first, then provider, so a
gateway-served model tagged provider "hanzo" (qwen3.5-397b, glm-5.2, kimi-k2.6,
minimax-m2.5) shows its true vendor instead of the house block-H. New pure
brandForModel() + model-aware ProviderLogo, wired at every per-model call site
(Marketplace card, Model Catalog detail, Playground picker). Hanzo stays ONLY the
fallback for genuinely-Hanzo/unknown models; Zen (id zen*) keeps the Hanzo mark.
Add canonical self-contained inline SVG vendor marks (no CDN, CSP-safe, theme-aware
white-on-brand-tile): Anthropic sunburst (new), OpenAI blossom knot (replaces the
invented asterisk), Google Gemini spark star (replaces the Gemma gem). Marketplace
card derives logo + vendor label (brandLabel) + house Verified badge from the one
brandForModel result, so a card is never Qwen-logo + Zen-label.
tsc clean; vitest 1667/1667 (+15 brand); marks render-validated headless.
Mobile/tablet responsive polish across the console shell + chat + overview. All
five are console2-local app compositions; the shared @hanzo/gui primitives were
already correct (bumping them globally would wreck desktop density).
- Chat composer sticky to the viewport bottom on phones/tablets (.hz-chat-dock),
so it never first-paints below the fold; static in the capped column at lg+.
- Touch targets >=44px (WCAG 2.5.5): nav-drawer rows/controls via .hz-touch-target
(the desktop sidebar is a separate mount and stays dense), the hamburger, and
the chat send button.
- Nav drawer: explicit close X inside the drawer header (right-aligned), always
reachable on a 390px phone (backdrop/Escape still close too).
- Overview KPI grid: responsive columns (1 / md:2 / xl:4) so a 4-card row balances
instead of wrapping 3+1 at tablet widths.
- Chat conversation column capped at ~820px, centered, so it no longer runs
edge-to-edge on ultra-wide displays.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
P0: hard-navigating/refreshing any sub-route (e.g. /machines) threw
"Application error: a client-side exception has occurred" and stayed dead
until a full reload. Root cause: on a rolling deploy the just-served HTML
references a content-hashed chunk that the replica/CDN the browser hits
does not have; the Next origin serves that missing chunk URL as HTTP 200
app-shell HTML (Content-Type text/html), so the browser parses HTML as JS
into "Unexpected token <" -> ChunkLoadError. The failure lands ABOVE every
segment boundary during first hydration, and there was NO app/global-error,
so Next fell to its built-in dead-ended fallback (no router -> a later nav
back to / stays dead).
Fix:
- app/global-error.tsx (NEW): the missing OUTERMOST boundary. Replaces Next's
dead default; on a chunk skew self-heals with ONE reload per window, else a
self-contained recovery card (owns its own html/body).
- ChunkGuard: add CAPTURE-phase resource-error detection so a raw chunk 404
on initial deep-load is caught before webpack's loader rejects; route it
through the shared once-per-window guard (no fast reload loop).
- boundary-logic: export CHUNK_RELOAD_AT_KEY — ONE loop-breaker shared by
global-error, the dashboard segment, ProductErrorBoundary and ChunkGuard
(DRY; removes two duplicated magic strings).
- generateBuildId pinned to the commit (src/config/build-id.mjs): SOURCE_COMMIT
build-arg -> git HEAD -> package version, so every replica of a release shares
ONE build id (default minted a random id per build). Dockerfile + CI pass the
SHA as a build arg (alpine has no git binary).
Gates: tsc --noEmit clean, 1659 unit tests pass (7 new for resolveBuildId),
next build green. Curl-proof: deep-load /machines and /training serve 200, and
all 24 referenced chunks (incl global-error and the slug page) resolve 200 JS;
.next/BUILD_ID now equals git HEAD sha.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
* feat(o11y): reusable per-product ProductObservability panel on every product overview
One DRY <ProductObservability service=/> panel (RED metrics + recent logs +
recent traces) over the existing ApmApi (/cloud/v1/o11y query_range, the ZAP-fed
datastore), filtered to each product's OTel service.name via o11yServiceFor.
Rendered at the bottom of the shared NativeOverview so every product surfaces its
own live signals. Honest states: loading, o11y RuntimeNotice, connected-empty,
and a managed note when a product has no backing service.
* feat(o11y): admin.hanzo.ai global Fleet Observability board (global-admin only)
Cross-org fleet o11y god view reading /v1/admin/o11y (the server-gated aggregate
I built in cloud): fleet KPIs (requests/tokens/cost/errors/p95-p99/log volume/
active orgs/services/traces), usage + log-volume timeseries, and top orgs/models/
services leaderboards — all tenants aggregated from the ONE datastore.
- AdminO11yApi.global(range) via originGet('admin/o11y') → the console-origin
admin-aggregate proxy (getAdminGate, fail-closed 403 for non-global-admin).
- 'o11y' added to ADMIN_AGGREGATE_HEADS + ADMIN_V1_HEADS (same gated path as
compute/finance — no new proxy/trust boundary).
- Defensive normalizer (snake+camel tolerant, garbage → honest zeros/empty).
- Registry entry 'fleet-o11y' (Observe, admin:true — hidden from customers);
client OperatorAccessRequired gate mirrors the server gate.
- Tests: normalizer (real payload/snake-case/garbage/NaN) + allowAdminSurface
admits o11y. Version 8.4.88 -> 8.4.89.
---------
Co-authored-by: hanzo-dev <dev@hanzo.ai>
The Finetuning + ML Pipelines pages showed 'Not enabled for your account' for a
signed-in customer, and Organization-Settings API keys couldn't be listed/minted.
Both are real money-path cracks — verified live as Dave (org maxpower).
ROOT CAUSE (proven live, NOT the iss theory):
- /training proxy forwarded the raw session COOKIE to cloud-api /v1/train/*, which
authorizes on a validated JWT principal => 403 'no validated principal'. With a
Bearer it is 200. (The token iss is already https://hanzo.id — IAM folds the
in-cluster host to originFrontend[0]; cloud-api accepts it. functions/app-platform
already work via the /cloud bearer proxy, so they were not the break.)
- keys.ts called cloud.hanzo.ai/v1/console/keys — a DIFFERENT origin than
console.hanzo.ai => browser CORS 'Failed to fetch' (and cloud-api 501s that
handler anyway).
FIX (surgical, isolation-safe, DRY):
- /training now mints a short-lived user-bound Bearer via adminBearer (the ONE
per-user cache the /cloud proxy uses) and forwards Bearer + X-Org-Id (orgFor pin);
the cookie is dropped upstream. Fails closed (502) if the token can't be minted.
Org stays server-authoritative (token owner claim) — no tenant-isolation change.
- New same-origin app/keys/route.ts uses identity.ts mintUserKey/getUserKey/
revokeUserKey (IAM confidential-client, the WORKING key path); keys.ts addresses
<origin>/keys. CSRF-guarded, honest 501 when unconfigured, secret shown once.
- Bundle the edge-503 hygiene: interpretPlatformError maps 503 -> 'unavailable'
(clean card), so a fail-closed zt backend never leaks ZT_CLIENT_* env to a customer.
tsc clean; vitest green; next build ok (/keys + /training routes registered).
Deterministic release from main HEAD (includes the :tab route fix v8.4.86, the
6 dead-Docs-button removals v8.4.87, and #94 route un-blank). Tagged so the build
is immune to the main-push concurrency-cancel war on the contended v8.4.87.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(console): mocked-network blank audit for every product route + product-content hook
* fix(nav): alias human product slugs (traces/deploy/plans-pricing/wallets/model-catalog/fine-tuning/web-search) → canonical ids
The single biggest source of 'half the pages are blank': a directly-navigated
URL whose slug != the registry id resolved to notfound -> a Next 404 the operator
read as a blank page. The nav/launcher/palette always open the canonical id, so
these 7 aliases exist only to keep a human slug (docs, bookmarks, the CTO e2e
list, a hand-typed URL) from 404ing. One map (SLUG_ALIASES), already applied by
resolveProductView via canonicalSlug. Proven end-to-end by the blank audit
(every alias slug now renders content, 0 notfound).
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
accessibility, crm, erp, templates, markets, trading set docs: ${DOCS}/<slug>
but those docs.hanzo.ai/docs/<slug> pages don't exist (verified HTTP 404 live) and
have no honest target — a redirect would mislead. Remove the docs: field on those
6 entries; consumers fall back to config.docsUrl (docs root), so no dead deep link.
The docs lane handles the other previously-missing slugs via docs-side redirects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On admin.<brand> the admin guard has already authenticated the operator at the brand
IAM (a live SSO session), yet /signin rendered <SignInForm/> on EVERY host — forcing a
SECOND manual login that resolves the tenant identity (hanzo/z), not the operator
identity (admin/z), which breaks the operator panel.
Now the signin page auto-initiates admin-console OAuth on an admin host (isAdminHost):
startAdminSignin() mints a PKCE verifier, stashes it for the callback, and redirects to
IAM /v1/iam/oauth/authorize for client_id=admin-console in organization=admin — reusing
the guard SSO session and returning to /auth/callback with a code, no extra step. The
callback hands the stashed verifier to completeSignIn, which redeems it via the console
own BFF (PKCE, no secret). Tenant hosts are UNCHANGED — still <SignInForm/>, no verifier,
cloud-backend exchange.
Loop-guarded: fires once, never with a live session, never mid-callback.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The Containers (Pods/Containers/Images/Namespaces/Events), Fine-tuning
(Datasets/Checkpoints/Models), and Tasks (Schedules/Queues/Workers) modules
render their tab bar as REAL sub-routes (go(t.id) -> /<id>/<tab>, reading
params.tab) but their registry entries declared only { path: '' } -- so every
tab 404'd (Tasks/Queues degraded to a stub). Declare the ':tab' route on each so
the tab bar resolves. Tasks keeps its 2-segment ':ns/:wid' detail route
(unambiguous by segment count).
Also fix the API Keys docs button: 'https://docs.hanzo.ai/api' -> the docs site
serves under /docs (a bare docs.hanzo.ai/<slug> 404s), so use
${config.docsUrl}/docs/api (white-labeled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared per-product sub-page system (Status/Logs/Metrics/Settings — already
routed for all 136 products) is now backed by live o11y, scoped per product by
its OTel service.name via ONE parameterized mechanism (no bespoke per-product
wiring). Reuses the existing ApmApi o11y client + RuntimeNotice + LivingOverview.
- sources.ts: o11yServiceFor(entry) — product -> OTel service.name (repoBase
convention + tiny override), new o11yService field on subpageSourcesFor.
- apm.ts: per-service o11y filtering — listQueryPayload gains optional filters
(back-compat), serviceFilterItem, ApmApi.logs/traceSearch(service?), and
ApmApi.serviceHealth (pickService + serviceHealthOf RED verdict).
- Status: LIVE o11y RED-metrics health band (org-scoped, works for customers) +
deployment state; managed card only when neither reports; never a fake green.
- Logs: live o11y logs filtered to the product's service (replaces the dead
/paas/logs path); RuntimeNotice + honest empty states.
- Metrics: real ledger + live o11y p99 merged into the latency KPI (was stuck
at '—'); latencyP95 -> latencyP99 (honest to the RED metric we have).
Rebased onto origin/main (v8.4.84). tsc clean; vitest 1643/1643 (129 files);
next build 14/14 pages. o11y is LIVE (o11y.hanzo.ai/api/v2/readyz=200).
Co-authored-by: Hanzo AI <ai@hanzo.ai>
The managed data products (Vector, SQL, KV, Datastore, DocDB, Search) all
drive ProvisioningApi, which addressed a bare /v1/<kind>. On the live console
ingress a bare /v1/* is routed straight to hanzoai/gateway (bypassing Next),
where the provisioning backends authorize on the Bearer owner claim and 403 a
cookie-only call ('X-Org-Id required'). classifyBackend maps that 403 to
'access' -> the FALSE title 'Not enabled for your account' on pages whose data
is actually live and per-org.
Class-fix (same as storage.ts /v1/s3 + framework, v8.4.70): ProvisioningApi now
builds cloudProxyV1Url(kind) -> <origin>/cloud/v1/<kind>. app/cloud/[...path]
mints a short-lived user-bound IAM token from the session and forwards it, so
the org is resolved server-side and the real resources load. All seven data
kinds are already allow-listed in proxy-allow.ts CLOUD_HEADS; the /cloud route
already serves GET/POST/PUT/PATCH/DELETE. One-line-of-intent transport swap.
Genuinely-empty orgs now show the honest 'Create your first ...' empty state,
never 'not enabled'. 'Not enabled' remains only for a true 403 enablement gate.
Tests: pin every ProvisioningApi call to <origin>/cloud/v1/<kind> (never a bare
/v1) in provisioning.test.ts, and move provisioning into the canonical-paths
'proxy exceptions' block (it was wrongly asserted prefix-free, encoding the bug).
tsc + vitest (1620) + next build green.
v8.4.84
Video generation is now async (Sora-style): create returns a job id immediately,
the client polls GET /v1/videos/{id} and downloads GET /v1/videos/{id}/content.
The /ai proxy's allow-list was exact-match and only permitted the CREATE path
(v1/videos/generations), so the Playground could reach create but neither poll
nor download — the two dynamic sub-paths 404'd at the proxy.
Add a narrow, anchored pattern (v1/videos/{id} and /{id}/content, conservative
id charset) alongside the exact set. It stays a tight allow-list — anchored to
v1/videos/, create still only the exact path — never a general tunnel; method is
enforced by the backend (GET-only there). Complements hanzoai/ai#68.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(tracker): native @hanzo/gui Tracker module — issues grouped by status
Add a native console Tracker over the real cloud /v1/tracker surface,
proving a native FE renders issue ROWS GROUPED BY STATUS (the thing the
old Huly/Svelte hanzo.team tracker could not do).
- src/lib/api/tracker.ts: TrackerApi client modeled on crm.ts — same
keyless originV1Url + plain-REST helpers; bare-array lists; defensive
pure normalizers; projects + issues CRUD (PATCH updates).
- src/lib/api/client.ts: add restPatch (backend uses PATCH; restRequest
gains 'PATCH') — one-way, minimal.
- src/components/products/TrackerModule.tsx: project list (create +
drill-in) → per-project grouped List (one DataTable section per
status, the proof view) + Board toggle (5 columns, same data);
create/edit issue in a SlideOver with a quick one-click status control;
Linear-grade `c` keyboard shortcut opens create (⌘K stays owned by the
global CommandPalette). Reuses only the local ui/ kit.
- registry.tsx: one Platform catalog entry (id 'tracker', ClipboardList).
- next.config.mjs + proxy-allow.ts: allow-list the `tracker` head in both
(BFF→cloud user-bearer proxy), exactly like `crm`.
typecheck (tsc --noEmit) clean; 1611 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(release): v8.4.83 — ship native Tracker module
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Fix 1 — per-family model icons (DRY, one map, every catalog surface):
Every model family rendered the same faint mark (the Zen ensō ring on every Zen
row — the biggest family — plus flat letter monograms Q/Me/DS/Mi/G/AI for the
third-party families), so nothing read as a recognizable brand. Added ONE curated
family→mark map (`src/components/ui/brand-marks.ts`, `BRAND_MARK` keyed by the
canonical BrandKey) of our own tasteful, monochrome inline-SVG marks, consumed
ONLY by `ProviderLogo` — so the Models catalog family headers + rows, the
playground ModelPicker rail + rows, Marketplace, Provider admin and Providers
explore all light up for free:
- Zen (house brand) → the bold Hanzo block-H, knocked out of a filled tile —
NEVER an upstream family glyph (brand policy); replaces the faint ensō circle.
- Qwen → origami hexagon · DeepSeek → whale · Meta → infinity · Mistral → block-M
· Google Gemma → cut gem · OpenAI GPT-OSS → six-point knot. (+ xAI/Moonshot/
NVIDIA marks for the broader provider picker.) Each on its brand hue, each
visually distinct; unknown providers keep the honest neutral-initials fallback.
No external logo hotlinks, no trademark files — avatars, `currentColor`,
theme-adaptive. brand.test locks every curated family to its OWN unique mark.
Fix 2 — CPU=credit vs GPU=prepay-card, made obvious:
The behavior was already enforced server-side; this makes the funding source clear
to the customer. New pure `fundingModel(kind,{creditCents,hasCard})` (machines/
logic.ts, unit-tested) is the one source of truth, rendered by a shared, visually
distinct `FundingNote`:
- CPU / non-GPU → GREEN "Launches on your Hanzo credit · $X available · charged
to credits · no card required" (real balance from /v1/billing/balance; empty →
"Add credits"). Shown in the launch drawer AND the Machines overview.
- GPU → YELLOW "Prepay only · charged to your card · 24-hour minimum" (never
credits; no card on file → "Add a payment card & prepay"). Shown in the launch
drawer AND the GPUs overview. Replaces the drawer's redundant no-card block.
Nothing fabricated — the credit figure is the real balance; copy matches what the
server charges.
typecheck clean · vitest 1611 pass (+ funding + brand-mark coverage) · next build ✓
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin-aggregate forwards a user bearer minted by issue-user-token for the
reserved-admin operator (admin/z). Its owner=admin + isAdmin=true are correct, but
its aud defaults to the target user's own app (admin-console), which is NOT in
cloud's audience allowlist — so SanitizeIdentity rejects the token, the request
resolves anonymous, and every /v1/admin/* 403s 'global admin required'.
Fix: scope the minted bearer to the brand cloud audience (<brand>-cloud, always in
cloud's BrandAudiences), host-aware, admin path only.
- config.cloudAudience(host) = BRANDS[brand].iamApp (correct even on admin hosts).
- issueUserToken(user, aud?) / adminBearer(user, aud?) cached per (user, audience).
- BearerProxyOpts.audience; admin-aggregate passes cloudAudience(host).
Tenant proxies omit audience → default (target-app) aud, unchanged; confidential
mint client (hanzo-console) and owner semantics untouched. No cloud change.
tsc clean; vitest 1603/1603; next build green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The default Overview (and every product overview + o11y usage panel) showed the
"Access required" wall because GET /v1/billing/usage returned 403. Root cause
(proven live as davelorenzini/maxpower): on the console ingress /v1/* is routed to
the gateway-fronted cloud binary, which requires a JWT bearer (AUTH_PUBLIC_PATHS
excludes billing) — a cookie-only browser request has none, so cloud-api's
clients/console/billing.go resolveCaller finds no validated principal and 403s
("sign in to view billing"). Same class proven by /v1/functions -> 403
"X-Org-Id required". The bare /v1/billing/* NEVER reaches the console Next server
(so the next.config /v1/billing -> /billing/v1 rewrite never fires).
The console's OWN per-tenant proxy app/billing/v1/[...path]/route.ts (service token
+ server-pinned org subject) works perfectly: proven live /billing/v1/balance ->
{available:2046235,user:"maxpower"} and /billing/v1/usage -> 287 real ledger rows.
wallet.ts already addresses it directly; billing.ts + aimetrics.ts + SettlementModule
regressed to the bare /v1/billing/* form.
Fix (same class as v8.4.70 framework/s3 -> /cloud/v1): add ONE billingProxyV1Url
helper (client.ts, the billing twin of cloudProxyV1Url) and point every billing
client at /billing/v1/* directly. Kill wallet.ts's duplicate local appUrl (DRY).
canonical-paths.test pins billing into the proxy-exceptions block; aimetrics.test
asserts /billing/v1/usage. Tenant isolation unchanged (proxy still pins the subject
server-side). No cloud/backend change needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One reusable ProductQuickLinks band, wired once in the product catch-all for
every product's Overview (native, living, or bespoke) — never hand-copied per
module. Each card is scoped to THAT product and links to a real destination:
- Billing → Cost Reports pre-filtered to the product's meter
(/billing/reports?product=<tag>; unfiltered for the whole-ledger inference
surfaces). BillingReports reads the ?product deep-link and filters the ledger.
- Usage → the product's own Metrics sub-page (/<id>/metrics), the REAL
/v1/billing/usage ledger scoped by metadata.product.
- Metrics → the same per-product Metrics dashboard.
Figures are REAL (one product-scoped UsageApi.overview read) or honest-empty —
never fabricated, never an Access-required wall; the links always work. The
product→meter decision reuses the ONE metricsScopeFor source (DRY), so Usage for
Models shows model usage, Usage for GPUs shows GPU usage. Suppressed for the
money/account/admin/rollup surfaces. Pure quick-links.ts is unit-tested (11).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leg 2 of the admin.hanzo.ai login. The credential login mints the code for the
PUBLIC admin-console app, but the redeem POSTed the console origin /v1/iam/signin,
which the ingress routes to the cloud backend (casibase) — and casibase redeems
with its confidential hanzo-cloud client, so IAM rejected it ("the token is for
wrong application (client_id)") and bounced the operator back to /signin.
The console now redeems the code ITSELF, host-aware: on an admin host iam-login
authorizes with PKCE (S256 codeChallenge in the login body) and completeSignIn
posts {code, codeVerifier} to the new BFF app/auth/signin, which runs
pkceCodeGrant(client_id=admin-console, code_verifier) with NO client secret —
RFC 7636 public-client path (verified in IAM GetAuthorizationCodeToken: empty
secret + matching S256 verifier is admitted; admin-console has the
authorization_code + refresh_token grants). Tenant hosts are unchanged (no
challenge; the cloud backend keeps redeeming with hanzo-cloud).
durableSessionClientId(host) is the ONE host->client decision (admin-console on
admin hosts, else the confidential hanzo-console); /auth/refresh uses it so an
admin session refreshes secretlessly with admin-console. accountOf + applyCookies
extracted to session.ts (one writer for /auth/session|refresh|signin); createPkce
is the one PKCE source. No new secret to provision.
tsc clean; npm test 1578/1578 (+durableSessionClientId/pkceCodeGrant/refresh
host-aware + PKCE S256 correctness); next build green (/auth/signin registered).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Problem 1 — GPU catalog unreachable ("Accelerators 0 available to launch").
ROOT CAUSE: the console host ingress routes /v1/* straight to the gateway
(-> cloud-api), BYPASSING Next -- so the next.config /v1/gpu-sizes -> /vm/v1/gpus
rewrite never runs and cloud-api serves NO visor catalog route -> empty catalog.
Same class as the framework/s3 fix (v8.4.70). Proven in-cluster: visor
/v1/{gpus,regions,sizes} = 200 real DO catalog; cloud-api /v1/gpu-sizes 404s.
FIX: new vmProxyBase/vmProxyV1Url (client.ts); VisorApi reads the catalog
(regions/sizes/gpus) through the /vm visor proxy EXPLICITLY and machines/
launch/quote/terminate through the /cloud user-bearer proxy -- never a bare
/v1/*. Regression-pinned in visor.test.ts (fetch-capture) + canonical-paths.test.ts.
Problem 2 — GPU launches bill a REAL card (prepay), never credits.
GPU billing policy surfaced in LaunchDrawer + enforced server-side (cloud-api +
commerce, tracked separately): PREPAY ONLY (card-funded prepaid balance, never
granted credits), CARD REQUIRED (no card on file -> launch BLOCKED with an add-card
CTA -> /billing/credits), 24-HOUR MINIMUM (hourly x 24 charged upfront, shown as the
Quote headline + "charged now" on Launch). Copy changed from "metered to your Hanzo
balance" -> prepay/card/24h-minimum. Card-on-file gate fails CLOSED (a failed
payment-methods read blocks the launch, never a silent credit fallback). CPU
machines unchanged (still metered to the credit balance).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared list primitive every module renders:
- Loading paints SKELETON ROWS in the real column layout (honest 'loading',
not a centered spinner void).
- Header: quiet Medium ($color10), hairline underline instead of a heavy fill.
- Columns gain `align` + `mono`: numeric/amount/ID columns right-align and
typeset in Geist Mono tabular figures (dashboard-grade, column-aligned data).
- Cells vertically center their content (justify=center); empty state calmer.
- Rows ease their hover fill (.hz-row, 140ms).
StorageModule: timestamps + byte sizes -> mono tabular; actions column ->
align:right + centered (drops the self=stretch flex=1 that floated the trash
icon below the row baseline). No other module used that pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Basel ships only Book (400) + Medium (500). Every heading/label requested
600-900 was browser-SYNTHESIZED into a smeared faux-bold. One global invariant
(`* { font-synthesis: none }`, wins over Tamagui's runtime reset) makes every
requested heavy weight fall to the real Medium face — crisp, never synthetic.
- globals.css: font-synthesis:none (universal !important + body); add .hz-mono
(Geist Mono + tabular) for dense data/code, keep .hz-tnum for display numerals.
- shared primitives normalized to the Book/Medium system: PageHeader, EmptyState,
Metric (MetricCard/Panel/LegendDot) titles -> 500 with tight tracking.
- LivingOverview tiles: hero KPI / donut / distribution / series numbers -> Basel
Medium + tabular figures (display numerals stay elegant; mono is reserved for
tables/IDs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The global-admin surface for launching, branding, domain-binding, and managing
white-label tenants + resold sub-orgs. The MANAGEMENT UI over the platform's
provisioning — tenant/package/domain/brand RECORDS are the single source of truth,
nothing hardcoded as the canonical path; honest-state everywhere (real where a
backend answers, honest not-connected where a platform endpoint isn't bound yet).
- TenantsModule (admin: true, category Platform) + tenants/: tenants list COMPOSED
from IAM orgs (brand) + admin cockpit (plan/wallet/status) + platform clusters;
reseller TREE derived from metadata.parentOrg or (honestly flagged) owner email;
package catalog read from the platform (DATA, seeded from platform-seed/packages.json,
never a hardcoded const); New-tenant create (real IAM org); per-tenant manage
(brand write REAL via IAM org fields, cluster provision REAL, domain list+bind,
IAM apps, package grant, suspend/reactivate REAL).
- BFF: no new proxy — all platform calls ride the existing /paas catch-all (light up
when the platform serves them; honest 404 today). Only server change:
add/update/delete-organization added to the /admin/iam allow-list (global-admin
gated, org-name pinned) so tenant-create + brand-write are REAL.
- Data-driven brand resolver: TenantsApi.brandConfig(host) + TenantBrandConfig
replace the hardcoded BRANDS/HOST_BRANDS map in config.ts (marked deprecated with
the precise migration; not swapped this pass — it's a build-time OAuth boundary).
- Missing platform endpoints flagged for the foundation phase: GET /v1/packages +
package table, POST/DELETE /v1/org/{org}/package/{id} (composite provisionPackage),
GET|POST /v1/org/{org}/domain (auto ingress+DNS+cert), GET /v1/brand?host=,
parentOrgId column.
- Tests: +41 (packages normalizer+seed, model compose/tree/infer, tenants-API
path/normalizer) all green; tsc clean for all new files; /tenants + /tenants/packages
render 200 with zero page errors through the admin-gated catch-all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-console walkthrough flagged /automation, /automations, /ml-pipelines,
/mlpipelines as raw 404s. Root cause: the nav opens each entry by its canonical
id (auto → auto.hanzo.ai external launch; kubeflow → in-console module), so a
directly-navigated/bookmarked conventional slug matched no entry and hit
notFound(). DNS + Zero-Trust already render honest BackendStateCard degraded
states (verified — no change).
- ML Pipelines: rename entry id kubeflow → ml-pipelines so the id IS the
intuitive slug (label was already 'ML Pipelines'; Kubeflow is the engine, in
the description). /ml-pipelines is now the canonical route.
- Slug aliases: ONE SLUG_ALIASES table in match-core (the single resolver) maps
automation/automations → auto, mlpipelines/kubeflow → ml-pipelines. canonicalSlug
rewrites the head segment up front in resolveProductView, so every branch reasons
over the canonical id. A module alias resolves to the real route; an external
target resolves to a new 'external' view kind.
- Catch-all: an 'external' view renders ProductInterstitial (the in-console
discover page with an Open button that launches the product's own domain) —
never a 404 for a hand-typed external-product URL.
DRY: aliasing in one place, no duplicate modules, no fake soon states, honest
by construction. tsc clean; vitest 1534/1534 (+10 match-core alias/external);
next build ✓ 14/14.
On an admin console host the operator could not sign into the cockpit: after the
admin-guard passed, console2s own app-login failed with
oauth2 invalid_grant: the token is for wrong application,
application.Name:[hanzo-cloud], token.Application:[admin-console]
Root cause: admin.<brand> switched the OAuth APP to admin-console but never
switched the ORG, so login resolved into the brand tenant org (hanzo) instead of
the reserved global-admin org (admin) where admin-console is registered — the
code was minted in the wrong org and the token audience mismatched.
BUG #1 (src/config/index.ts resolveConfig): iamOrgName never switched on an admin
host. The app and the org now travel together — a sibling of the existing
app-switch: const admin = isAdminHost(host); app = admin ? adminApp : iamApp;
org = admin ? ADMIN_ORG : b.iamOrgName. NEXT_PUBLIC_* override precedence
unchanged. ADMIN_ORG (=admin) is ONE global org across every brand.
BUG #2 (src/lib/auth/iam-login.ts): the direct credential login hardcoded
organization: (a TENANT multi-org behaviour) on every host, so the admin host
never pinned org=admin. Now organization = isAdminHost(window.location.hostname)
? config.iamOrgName : — admin.<brand> authenticates INTO admin, tenants keep the
resolve-across-orgs-by-email behaviour (unchanged). The login wire already
derives its host + redirectUri client-side from window.location, so the SSR
build-time default host never enters the login flow.
Tenant (console.hanzo.ai) auth and the admin-guard org-membership gate are
untouched.
Tests: resolveConfig(admin.hanzo.ai) -> {admin-console, admin-console, admin};
resolveConfig(console.hanzo.ai) unchanged; new iam-login.test.ts proves the
credential POST carries client_id/application=admin-console + organization=admin
on admin.hanzo.ai and brand-app + empty org on a tenant host.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The org > project selector ships already (ScopeSwitcher in the shell,
ScopeProvider in app/(dashboard)/layout.tsx, selection persisted via
lib/scope + localStorage). scope.test.ts proves the STORE; nothing proved
the WIRE — that a selected project actually reaches X-Project-Id on every
cloud call (the header visor attributes org>app>project usage by, spoof-
proofed at cloud.SanitizeIdentity).
Black-box test through a real get() over a stubbed global fetch:
- org-level (no project): org-scoped call, NO X-Project-Id
- project selected: X-Project-Id stamped, still under X-Org-Id
- project cleared: X-Project-Id drops back to org-level
- active environment rides along as X-Environment
No production change; locks the contract a customer scoping by project relies on.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Live: console.hanzo.ai/playground → "Could not reach the backend — HTTP 502",
model selector empty ("Choose a model"), Run blocked. ROOT CAUSE: the
ChatPlayground catalog fetch (aicatalog.fetchCatalog via useModels) hard-depended
on /v1/pricing/models — that endpoint 502s on the live ingress and its restGet had
NO .catch, so the whole Promise.all rejected even though /v1/models (200, the full
~59-model DO-first catalog incl the zen5 family) succeeded right beside it. The
working /v1/models result was discarded → 502 card → no model preselected.
- Fix 1 (catalog reachability, DRY): fetchCatalog now makes /v1/models the PRIMARY,
always-routed source and /v1/pricing/models a best-effort overlay (the EXACT
resilience CloudModelApi.list already uses). When pricing 502s it falls through to
the live set; live-only entries are normalized name<-id, provider<-owned_by so the
picker row never renders blank (also fixes a pre-existing latent blank-name).
Throws only if the live /v1/models set itself is unreachable. Marketplace +
ModelCatalog (the other fetchCatalog consumers) get the same resilience.
- Fix 2 (auto-select promoted Zen): new pure default-model.ts (defaultModelId,
extracted from useModels so it's node-testable without the hook's UI imports;
re-exported, callers unchanged). Default = latest PROMOTED Zen flagship: honor an
explicit catalog `featured` flag first (auto-tracks zen6 with no code change), else
the bare Zen flagship by name (zen5) over a mini/flash/coder tier, else any servable
text model. ModelOption carries `featured`. ChatPlayground seed effect is retry-safe.
tsc --noEmit=0; vitest 1502 pass (+9 default-model, +2 aicatalog 502-resilience;
the pre-existing canonical-paths.test.ts s3 case is RED on origin/main HEAD — the
v8.4.70 storage.ts->cloudProxyV1Url move left that expectation stale — UNRELATED:
storage.ts/canonical-paths.test.ts/client.ts untouched); next build 14/14.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
The v8.4.70 build break (framework/client.ts + storage.ts imported the
cloudProxyV1Url that #81 DELETED → tsc TS2305; next build type-checks, so CI
shipped no new image) blocked BOTH live CTO reports from deploying: the authed
overview (sound code — UsageApi.overview → /v1/billing/usage, LivingOverview
degrades to an honest ErrorState) and the Qwen/Llama/DeepSeek model brand
icons (already fixed in 6dfa9c059: Qwen #615CED, Meta #0866FF, DeepSeek #4D6BFE).
The build fix landed concurrently in c458efa8f (re-add cloudProxyV1Url — the
prod-correct variant: the live ingress does NOT rewrite bare /v1/s3, /v1/framework
to the console app, so those heads address /cloud EXPLICITLY). But it left vitest
RED: canonical-paths.test.ts still asserted the old prefix-free
StorageApi.buckets → /v1/s3/buckets. This restores green + pins both invariants:
- canonical-paths.test.ts: drop the stale prefix-free s3 assertion; add a
documented cloud-proxy-exceptions block pinning s3 + framework to /cloud/v1/*
(so a future canonicalization can't repoint them to a bare /v1/ that 403s live).
- families.test.ts: a families↔brand contract test — every curated family logo
resolves through the ONE normalizeBrand→BRANDS resolver to a real colour + icon,
keyed off the exact live pricing.json providers (Qwen/Meta/DeepSeek). Permanent
guard for the 'icons blank' report.
Test-only (runtime fixes already on main). tsc 0; vitest 1507/1507 (121 files);
next build ✓ 14/14.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
v8.4.70's framework + storage clients import cloudProxyV1Url to address the /cloud
bearer proxy explicitly (a bare /v1/framework or /v1/s3 hits hanzoai/gateway with no
principal → 403). But that helper had been deleted from client.ts by the earlier
/v1-canonicalization, so the build failed: "'cloudProxyV1Url' is not exported from
'./client'". Re-add cloudProxyBase + cloudProxyV1Url (the <origin>/cloud/v1/<path>
builder) so the CMS/ERP/Help framework calls and the media DAM S3 calls resolve and
reach the working proxy. next build green (14/14).
Same ingress-bypass class as the framework fix: StorageApi built its URLs with
originV1Url → `/v1/s3/*`, which console.hanzo.ai's ingress routes DIRECTLY to
hanzoai/gateway (bypassing Next), so the request lands with no principal → 403
"valid principal required". This broke BOTH the S3 file-manager product AND the CMS
media DAM (media-upload.ts presigns uploads via StorageApi).
Fix: cloudProxyV1Url → `/cloud/v1/s3/*` (the `/cloud` route reaches app/cloud's
bearer proxy; `s3` is allow-listed in proxy-allow.ts CLOUD_HEADS). Presigned PUT/GET
URLs are absolute S3 and unaffected — only the minting calls (buckets/objects/
presign) move to the proxy. Tests updated to the corrected /cloud/v1/s3 path (15
pass). Ships in v8.4.70 alongside the framework fix.
The CMS "Content" page showed "Not enabled for your account" for a real user whose
org HAS the cms module installed. Root cause: the framework client built its URLs
with originV1Url → `/v1/framework/*`, but on console.hanzo.ai the INGRESS routes
`/v1/*` DIRECTLY to hanzoai/gateway (bypassing the Next.js app), so the next.config
`/v1/framework → /cloud/v1/framework` rewrite never runs. The gateway has no
principal for that path and returns 403 "valid principal required" → the module
renders its honest access-denied card.
Fix: build framework URLs with cloudProxyV1Url → `/cloud/v1/framework/*` (the same
per-tenant bearer-proxy path CRM/Prompts/Agents use, allow-listed as the `framework`
head in proxy-allow.ts). The `/cloud` route DOES reach Next's app/cloud proxy, which
mints a short-lived user-bound token and forwards to cloud-api with the org resolved
from the token owner. Verified live: `/cloud/v1/framework/doctypes` = 200 with the
real doctypes for maxpower; `/v1/framework/doctypes` = 403 (gateway). One-line import
swap to an already-exported, already-used helper; tsc clean, framework client tests
pass.
The Inference · Logs view already streamed the org's REAL recorded inference
calls (one commerce-usage-ledger row per billed call), but the rows were not
clickable and the LogLine projection discarded the rich per-call fields. Close
that gap — each row now opens the shared DetailPane showing what actually
happened for that call: model, provider, outcome, cost, prompt/completion/total
tokens, streamed, tier, product/agent attribution (only when the ledger tagged
it), request + transaction id, and time — every value REAL from the ledger
record, honest em-dash for absent. The full prompt/response TEXT is not on the
ledger row, so it is honestly stated as streaming from observability once its
trace runtime is connected — never fabricated.
DRY, no new surface: enrich LogLine with its source UsageRecord + one pure
logDetailFacts projection (logic.ts), one openLogDetail slide-over over the
existing DetailPane (panes.tsx, identical descriptor form to openEndpointDetail),
and wire onRowPress + a chevron affordance (LogsView.tsx). Reuses the shared Fact
row, StatusDot, and PrimaryButton — same look and feel as every other detail
surface.
Verification: tsc --noEmit clean; vitest 1136/1136 (+4 logDetailFacts /
LogLine.record); next build compiled successfully. Live authenticated render is
gated behind the console's server-cookie AuthGate (no backend session locally),
so verified via the component prop-level tests + a clean /[...slug] compile that
serves /inference/logs 200 in the dev server.
The Nodes surface (Network category, enabled on lux/zoo/pars + hanzo) showed
validators + peers per luxd primary network, but not the network's chains. This
adds the live primary-network chain set — the letter chains X C D Q A B T Z G K
plus the P-Chain — read from `platform.getBlockchains` through the same
same-origin, session-gated, method-allowlisted `/nodes` proxy.
- `/nodes` proxy: `platform.getBlockchains` added as the 5th (and only new)
allowlisted luxd read method. A network counts as reporting if validators,
peers, OR chains answered; chains are best-effort (a network can report
validators yet not answer getBlockchains → honest empty chain list, never
fabricated chains).
- `nodes.ts`: `RawBlockchain`/`ChainInfo` types + PURE `normalizeChains`
(prepends the P-Chain, which getBlockchains omits; preserves reported order;
drops id-less chains). `NetworkInventory.chains` added.
- `NodesModule`: renamed to "Networks & Nodes"; per-network card gains a Chains
count + live chain chips; a Chains table (Network · Chain · Blockchain ID · VM)
renders above the validators/peers table, honoring the network filter.
- Tests: +3 normalizeChains cases over the real devnet wire shape (33/33 pass).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The still-valid residual of #77 (which #81's canonicalization couldn't carry — #77
imported the now-deleted cloudProxyV1Url). Two real bugs, redone canonically on main:
1. o11y annotation-queues + users reads hit the DIRECT-cloud origin (`v1Url` ->
config.cloudUrl). The o11y runtime scopes tenancy by the minted bearer's owner and
403s a cookie-only call in prod, so those 4 reads (annotationQueues/annotationQueue/
annotationQueueItems/users) were dead on the deployed console. Switched to the ONE
canonical `originV1Url('o11y/…')` -> `/v1/o11y/…`; next.config rewrites the o11y head
to the `/cloud` bearer proxy (server) and the static embed reaches it directly — the
exact transport every other o11y read (ServiceMap/Alerts) already uses. The #41 sweep
only missed these because they were `v1Url`, not the deleted prefixed helper.
2. The `observations` + `users` catalog entries were TRAPPED inside registry.tsx's
opening JSDoc block (the `/**` never closed before them), so they never registered in
nav/routing despite ObservationsModule/UsersModule existing, being imported, and
fetching real data. Closed the comment and moved both entries into the active catalog
under Observe (beside Annotation Queues), so they render in the sidebar and route via
the catch-all like their siblings.
Supersedes #77 (unmergeable — it referenced the deleted helper). Verify: tsc --noEmit
= 0; vitest 1475/1475 (120 files); next build ✓ (14/14, the /[...slug] catch-all that
renders the catalog compiles). Authenticated visual e2e (the 4 o11y reads returning
real data; Observations/Users in the sidebar) is post-deploy.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Two new white-label brands recognized by the unified console so their hosts
render branded, mirroring lux/zoo/pars:
- config: BrandId gains '7stars' | 'yotoda'; BRANDS + HOST_BRANDS entries.
Both are general Hanzo-cloud customers seeded AS ORGS in the hanzo IAM
(hanzo.id) — they have NO own .id issuer, so iamUrl = https://hanzo.id with
the per-brand iamOrgName (7stars/yotoda) + iamApp (7stars-cloud/yotoda-cloud).
Login resolves against hanzo.id, org-scoped by the JWT owner (aud=<brand>-cloud),
matching how the orgs/apps were provisioned. Own billing.<domain>/docs.<domain>.
HOST_BRANDS suffixes 7stars.dev / yotoda.tech cover every subdomain
(cloud.*, console.*, admin.*) via the endsWith('.'+suffix) match.
- brand-scope: BRAND_CATEGORIES null (FULL AI-cloud catalog, like hanzo — they
are general cloud customers, not web3-only like the sovereign-chain brands).
BRAND_NODE_NETWORKS [] — they own no chain, so the Nodes surface reports on no
networks (never another brand's chain).
- branding/brands: BRANDS registry gains 7Stars/Yotoda with their own
brandName/orgName/websiteUrl/adminDomain (adminDomain is the admin-gate email
boundary — @7stars.dev / @yotoda.tech match the seeded owners z@7stars.dev /
z@yotoda.tech). Logo falls back to the generic Hanzo blocky-H mark (no bespoke
asset yet); swap logoContent when a real mark ships.
Tests: index.test.ts (host resolution, hanzo.id issuer, per-brand billing/docs,
admin app) + registry-brand.test.ts (full-catalog scope, zero node networks).
npm test 975/975 green. tsc/next build add zero new type errors (diff-proven
identical to origin/main; the pre-existing next/navigation nullable errors are a
local Node 26 vs CI Node 24 toolchain drift, not from these files).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Markets" product (Web3) — the analytics/management plane for the Lux DEX
economy, the twin of the Trading (deploy/manage) module.
- lib/api/economy.ts — client + pure normalizers for the `dex` subgraph
(markets/fills/day-data). Honest to the CLOB reality: 24h volume, trades, book
depth, best-bid/ask, last price are real fields; USD TVL is NOT fabricated (a
CLOB has depth, not pooled TVL); the day-history series is empty until the
subgraph's MarketDayData producer emits.
- overview/living: fromLuxIndexer adapter + a `lux-economy` LivingOverview config
(KPIs, volume/trades/depth donuts, recent-trade feed, maker-health row) — the
reusable board machinery, one config + one adapter, no new overview UI.
- app/economy/[...path]/route.ts — session-gated, brand-scoped GraphQL proxy to
graphd's `dex` subgraph (ONE fixed query, no client GraphQL); honest not-reporting
when unreachable.
- MarketsModule — the living board + the DeFiLlama-style per-market table, both over
the /economy proxy. Registered under Web3 (lux.cloud shows it).
Data source: luxfi/graph `dex` subgraph (markets/fills) + the maker :2112 metrics.
Tests: 23 new (economy normalizers + fromLuxIndexer); full suite 1468 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a first-class "Trading" product under Web3: deploy the market-maker and
trader bots to the Hanzo PaaS from a config form, list the org's deployed bots,
watch each one's live quote quality (the maker's :2112 metrics) and DEX order
book, and control them (start/stop/redeploy/logs).
- lib/products/trading/templates.ts — the two deployable-app definitions
(maker + trader) with a typed config schema; toCreateAppInput maps a filled
config → a PaaS git app (BuildKit builds luxfi/{maker,trader} → GHCR).
Signer keys are secretRef fields (KMS-synced), never typed in the browser.
- lib/api/trading.ts — pure Prometheus-metrics + order-book normalizers.
- app/trading/[...path]/route.ts — session-gated, brand-scoped, method-allowlisted
proxy (mirrors /nodes): scrapes the maker :2112 metrics + reads the DEX book,
honest not-reporting when unreachable.
- components/products/TradingModule.tsx (+ trading/{logic,DeployForm}) — the
list/status/orderbook views + deploy/start/stop/redeploy/logs, over the existing
PaasApi control plane (one deploy path; the bots are ordinary PaaS git apps).
- registry: Trading entry (Web3, brand-agnostic; data brand-scoped in the proxy
so lux.cloud sees only Lux networks).
Tests: 34 new (templates/normalizers/logic), full suite 1311 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every /cms/* URL now renders the native DocType renderer over /v1/framework/* — no
iframe, no raw JSON, no 404. Fixes the live console.hanzo.ai/cms/collections/Article
-> {"error":"Not found"} bug (the deployed image lacked the wired CMS sub-routes, so
the catch-all resolved them to notFound() and a Next RSC navigation served a JSON
404). The routes were declared correctly; this ships them complete.
- Rich text = native Lexical (the same engine Payload's MIT richtext-lexical uses),
built fresh + thin on core lexical@0.46.0 and registered over @hanzo/data's
`richText` type in Provider.tsx (registerField override, no fork). Toolbar:
bold/italic/underline, H1-3 + paragraph + quote, bullet/number lists, links,
undo/redo. Stores the Lexical EditorState JSON; read view -> sanitized HTML via
$generateHtmlFromNodes. Pure serialization round-trips + migrates legacy plain
Text bodies (never throws). A DocType field typed RichText renders it.
- Content-type builder: "New collection" defines a DocType's name + typed fields
on-page (add/remove/reorder/require/list, every framework fieldtype with the extra
inputs each needs). Pure builder-logic.ts.
- Media = real DAM: drag/drop or pick -> uploads to the org's own S3 (cms-media
bucket, the same /v1/s3 SeaweedFS presigned-PUT as Storage) -> Media doc with the
stable object key -> thumbnails presigned on-view; delete removes doc + object.
- Publish/Unpublish (+ Submit/Cancel) in the record editor.
- Project scope: the org->project ScopeSwitcher filters the records list and stamps
new records, only on collections that declare a `project` field. One engine,
project is a filter — no per-project/per-org CMS instances.
tsc clean; vitest 1418 pass (+richtext/builder/media/project/richText round-trip);
next build 14/14. Needs cloud v1.786.52+ (RichText fieldtype) deployed to accept a
RichText field live.
hanzoai/auto (auto.hanzo.ai) — visual AI workflow automation over 400+ MCP tools
and agents (the n8n/Zapier surface) — was not in the console. Add it to the AI
category as an external launch tile: it's a standalone app with its own full UI
on shared Hanzo IAM, so the tile opens it already-signed-in (like the Lux/Zoo
chain apps). Scoped brands:['hanzo'] so the auto.hanzo.ai URL never leaks onto a
Lux/Zoo white-label console.
Tests: 90 registry/brand tests pass; tsc clean.
The CTO contract is "nothing before /v1/". PR #79 (482251e) canonicalized 7 clients
but 3 helpers still hand-rolled a service-prefixed `<origin>/<svc>/v1/` URL —
aiV1Url (/ai), cloudProxyV1Url (/cloud), commerceProxyV1Url (/commerce) — fanning
out to 6 data-product clients AND ~13 product modules. DELETING them (not just
redefining) makes a non-canonical path COMPILER-IMPOSSIBLE: there is now ONE url
builder for the whole /v1 surface (originV1Url), exactly like billing/visor/
provisioning post-#79. Every remaining caller builds the bare, prefix-free
`/v1/<resource>`; next.config rewrites each head to its UNCHANGED same-origin BFF
proxy (app/ai, app/cloud, app/commerce — service-token / user-bearer injection intact).
- delete aiV1Url/cloudProxyV1Url/commerceProxyV1Url + aiBase/cloudProxyBase/
commerceProxyBase; repoint all callers to originV1Url (compiler-enforced, no caller left).
- aicatalog/embeddings: /ai/v1/{pricing,plans,models,embeddings} -> /v1/… ; add
`pricing`+`plans` to AI_V1_HEADS (already in the /ai proxy ALLOWED set).
- functions/paas/framework + Builds/Environments/Pipelines/Releases modules:
/cloud/v1/<h> -> /v1/<h>; new CLOUD_PRODUCT_V1_HEADS (functions/framework/
environments/pipelines/builds/releases) rewrites -> /cloud (already in proxy-allow
CLOUD_HEADS). apm(o11y) + paas(platform) heads were already rewritten.
- commerce: /commerce/v1/<x> -> the canonical namespace /v1/commerce/<x>, ONE rewrite
-> /commerce/v1/ (the billing twin) — collision-proof vs the generic store heads
(product/order/user/store). Local cUrl() namespaces once, in one place.
- extend canonical-paths.test.ts: aicatalog/apm/commerce/embeddings/functions/paas each
assert /v1/<resource> + never /<svc>/v1/. Realign functions.test + client-retry
illustrative URLs to the canonical form.
grep -rE '/(cloud|vm|ai|billing|org|commerce)/v1' src/lib/api/*.ts is clean.
tsc --noEmit ok; vitest 1439 pass; next build ok. Every console API call is now /v1/<resource>.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
On console.hanzo.ai the ingress routes /v1/* straight to cloud-api, bypassing the
keyless /ai bearer proxy — so a /v1 image call reaches cloud with NO user Bearer
and 401s (premium image gen requires auth). Call /ai/v1/images|videos/generations
directly (this app → forwardWithUserBearer mints the user-bound bearer) so the
Playground Image/Video tabs generate real, per-user-metered media.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/erp-help-native (e2baba7): ERP + Help Center now render through the SAME
generic DocType renderer (src/components/doctype/*) that draws CMS, over the ONE
framework surface /v1/framework/*. Deletes the EmbeddedApp iframe subtree
entirely: EmbeddedApp.tsx, ProvisionPanel.tsx, embed-hosts, embed-probe,
api/embed, api/cms, api/erp, and the app/{cms,erp}/[...path] proxy routes
(-1872 lines). ERP + Help + CMS are ALL native now — zero iframe in the console.
Resolved package.json version to 8.4.66 (above live v8.4.65).
Decomplect: make a non-canonical API path architecturally impossible for the
data-product surface. The 7 clients that hand-rolled a service-prefixed
/<svc>/v1/… path (billing, aimetrics, compute, visor, platform, provisioning,
storage) plus the Settlement component now build a bare /v1/<resource> via the
one originV1Url helper; next.config rewrites each head to its hardened
same-origin BFF proxy (service-token / user-bearer injection unchanged). Also
stamp X-Actor-Id (the signed-in user) in baseHeaders alongside
X-Org-Id/X-Project-Id, so org+project+user pass on EVERY call.
- billing/aimetrics: /billing/v1/<x> -> /v1/billing/<x> (rewrite -> app/billing/v1)
- compute: /cloud/v1/gpus[/alerts|/pools] -> /v1/gpus… (rewrite -> /cloud)
- visor: /cloud/v1/machines… -> /v1/machines…; /vm/v1/{regions,sizes} ->
/v1/{regions,sizes}; /vm/v1/gpus (catalog) -> /v1/gpu-sizes
(DISTINCT head: /v1/gpus is the cloud-api INVENTORY, not the catalog)
- platform: /cloud/v1/{clusters…,org/…/cluster} -> /v1/… (rewrite -> /cloud)
- provisioning/storage: /cloud/v1/{sql,vector,…,s3/…} -> /v1/… (rewrite -> /cloud)
- delete the per-client base-path builders (billingUrl / vm / clustersUrl-via-cloud);
grep -rE '/(cloud|vm|ai|billing|org)/v1' src/lib/api/*.ts is clean (only the
client.ts BFF-helper docs for the out-of-scope clients remain).
- X-Actor-Id sourced from a new lib/actor-scope (SessionProvider keeps it in
lockstep with the resolved account: the auth twin of org-scope).
tsc --noEmit ok; vitest 1432 pass; next build ok.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Frontend CX defects found in a live deep-test of console.hanzo.ai:
- Compute/Machines: a non-200 from /cloud/v1/machines (esp. 403/5xx) rendered
the empty "Launch your first machine" state — a permission/load error
masquerading as "you have none", the opposite of the page's "nothing is
fabricated" promise. interpretVisorError now maps 401 -> sign-in, 403 ->
honest permission state, and any other non-200 -> a retryable load error;
CustomerMachines shows the empty/launch state ONLY on a real 200-with-zero.
- Chat: Enter did nothing but insert a newline. @hanzogui/input swallows the
onKeyPress prop (never wired to the DOM) and forwards onKeyDown; the newline
default fires on keydown, so the send handler must live there. Enter sends,
Shift+Enter is a newline, IME composition never sends.
- Chat: model chain-of-thought leaked into the answer bubble. New pure
splitThinking() separates a final answer from <think> reasoning (streaming-
safe); the bubble renders only the answer with reasoning behind an optional,
collapsed disclosure.
- Observe/Logs: landed on the empty "Application logs" tab while "Request
activity" (always real for the org) had data. Request activity now leads and
is the default tab.
Team members (#4) already routes through the single canonical /org/iam
get-users path at HEAD — no dead-endpoint waterfall remains to remove.
Build gate: tsc --noEmit, vitest (1418 tests), next build — all green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
ERP and Help Center join CMS as NATIVE lanes on the Hanzo Framework — thin
hosts scoping the SAME generic renderer (components/doctype/*) to module=erp /
module=help, with ZERO per-doctype UI code (the DRY proof). This finishes the
Great Unification: CMS + CRM + ERP + Help all native DocTypes, all iframes dead.
- ErpModule/HelpModule rewritten as thin hosts (like CmsModule): collections
browser + records list + record detail, routed under /erp/collections and
/helpdesk/collections. Install CTA installs the lane's DocTypes/hooks;
submit/cancel + status flow come from the schema (no ERP/Help-specific UI).
- registry: erp + helpdesk -> native module routes (collections/:doctype +
:name), repo hanzoai/cloud, native descriptions.
- CollectionsBrowser: additive optional setupDescription/setupBullets so the
pre-install empty state reads correctly per lane. CMS default byte-identical
-- no behavior/permission/proxy change (the RED-passed path is unchanged).
- Kill the iframe/embed subtree ENTIRELY (finishes the unification): the
Frappe/Payload proxy route handlers app/erp + app/cms are Next catch-alls that
SHADOWED the native /*/collections SPA routes (a route handler wins over the
[...slug] page) -> deleting them unshadows native ERP AND native CMS (CMS was
latently shadow-broken since 8.4.63). Removed the now-dead EmbeddedApp /
ProvisionPanel / EmbedApi / CmsApi / ErpApi + embed-hosts + embed-probe + their
tests. No iframe/embed path remains anywhere in the console.
typecheck clean; vitest 1360/1360 (109 files); next build green (the /cms +
/erp proxy routes are gone from the manifest, so /*/collections reach the SPA).
Adds Image and Video tabs to the Playground, symmetric to Audio/Chat:
- ImagePlayground: Zen image model + prompt + size → POST /v1/images/generations
→ renders the real image (hosted url or inline b64).
- VideoPlayground: Zen video model + prompt → POST /v1/videos/generations
→ renders the real clip (base64 MP4 blob or url).
- PlaygroundApi.images/videos (src/lib/api/playground.ts) ride the SAME keyless
/ai bearer proxy chat/audio use; invalidateBalance() after each (metered).
- Open images/videos in the /ai proxy allow-list (route.ts) and the
next.config.mjs AI_V1_HEADS rewrite. No new auth, no billing bypass.
Zen-brand model ids only; pickers filter to the image/video families.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The model catalog rendered logos from each model's raw provider string, so the
Zen family flip-flopped (models tagged 'hanzo' → block-H, others → ensō) and
every third-party family fell through to a gray initials chip ('QW'). Fixes:
- Rows + detail now render the model's FAMILY brand (not raw provider), so a
family is internally consistent: Zen is ALWAYS the ensō; Qwen/OpenAI/etc. each
show one mark. (Directive: Zen provider always uses Zen.)
- Extract pure brand resolution into ui/brand.ts (normalizeBrand + BRANDS
registry) — unit tested, no GUI deps. Every curated family key resolves to a
real brand-colored tile (Qwen/OpenAI/DeepSeek/Meta/Mistral/Google + Anthropic/
GLM/Kimi/MiniMax/Nvidia/xAI); only genuinely-unknown providers get neutral
initials (honest, no fabricated trademark logos).
- Mobile: row hides the context column on phones + narrows numeric columns so
rows never overflow (mobile-first, $md restores the desktop layout).
Tests: 27 pass (brand 6 + families 21). tsc --noEmit clean.
Replaces the 'Jobs' tab/JobsView (a bespoke async-tracker) with an Ingest surface over
the ONE /v1/docs/ingest endpoint: three real sources (pasted text · GitHub repo · website)
+ a target collection. Text indexes inline; a repo or crawl returns a durable hanzoai/tasks
workflow id and the UI links to the ONE Tasks product to track it ('Track in Tasks →',
/tasks/<org>/<wid>) — there is no second async system. Lower panel = the store's REAL
indexed files (get-files), reframed honestly as 'Indexed files' not a job log. Tab + subpage
renamed jobs→ingest. EmbeddingsApi gains ingestGitHub/ingestCrawl; IngestStats gains
async/workflowId. tsc clean.
Replaces the cms.<brand> Payload iframe/Studio embed with a NATIVE, metadata-
driven surface over the LIVE /v1/framework/* DocType engine. Ships the DRY
foundation the ERP/CRM/Helpdesk lanes reuse: ONE generic framework client + ONE
generic DocType renderer (the 'one engine + one renderer renders every app' model).
- src/lib/framework/{types,client,fields}.ts — the ONE FrameworkApi client
(doctypes/records/modules/roles over the /cloud bearer proxy, allow-listed as
the new 'framework' head) + the pure mapper DocType metadata <-> @hanzo/data
FieldDefinition/record for EVERY fieldtype (relation/select/currency/attach/
check/datetime/…), relation label enrichment, slugify (URL-safe names),
publish/media/collection helpers. 32 pure unit tests.
- src/components/doctype/* — the generic renderer over @hanzo/data's RecordsView/
RecordDetail/RecordForm: CollectionsBrowser (module doctypes + first-run install
+ new-collection), DocTypeRecords (table, or the MediaGrid gallery for a media
doctype; inline edit sends the FULL validated body), DocTypeDetail (view/edit/
create/delete + publish/unpublish + submit/cancel). Zero per-doctype code.
- CmsModule.tsx is now a thin host scoping the generic renderer to module=cms.
- proxy-allow: the 'framework' head; registry: cms native routes, repo hanzoai/cloud.
Names are slug/hex only (slugify + isValidDoctypeName) so they are space-/%-free —
correct on the live engine AND through the console's own pathIsClean bearer proxy.
Per-org + honest-empty by construction: the engine enforces tenancy (principal.
Tenant) + per-DocType permissions server-side.
Cloud side (hanzoai/cloud): the CMS content model (Page/Post/Article/Media/
Navigation/Author, module 'cms') + the generic app-lane install
(POST /v1/framework/modules/cms/install).
Verify: tsc clean; vitest (framework 32); next build ✓ (14/14 pages).
o11y's last two query signals — application LOGS and trace search — are the
composite POST /api/v3/query_range (GET /api/v1/logs is a stub). Added to the
existing ApmApi (DRY, one o11y client, same /cloud/v1/o11y/* convention as
ServiceMap + Alerts): logs()/traceSearch() + pure builders/parsers
(listQueryPayload, parseListRows, toIso, normalizeLogRow/Logs, normalizeTraceSpan/
Spans). LogsModule is now two real lenses — Application logs (live o11y logs,
range + severity/service filters, honest RuntimeNotice/empty states) and the
prior Request activity ledger lens (kept, always-real fallback). Traces/
Observations stay on /v1/evals (LLM domain), Metrics on VictoriaMetrics — no
regression. tsc 0 errors; vitest 1373/1373 (+13 apm); next build ✓.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
The LaunchDrawer pre-fills a fun `adjective-animal` name (dark-llama, cosmic-axolotl,
turbo-wombat) so launch is click-and-go; a 🎲 button re-rolls it, it re-rolls after each
launch, and it stays fully editable. Pure curated word lists (src/lib/naming.ts), no deps.
tsc clean; vitest 1355/1355; next build ✓. Completes #43.
Pre-fill the machine/GPU launch drawer's Name field with a Docker/Heroku-style
adjective-animal name (dark-llama, cosmic-axolotl, turbo-wombat) so a user can
click-and-go and rapid-launch. A 🎲 button re-rolls on demand, and the name
re-rolls after each successful launch so repeat-clicking Launch keeps getting a
fresh fun name. Still fully editable.
- src/lib/naming.ts — pure adjective-animal generator (curated lists, no deps);
randomName({ suffix }) adds a short base36 token only when uniqueness is needed.
- LaunchDrawer: lazy useState(randomName) auto-fills on mount; post-launch
re-roll (the drawer instance persists across the DetailPane close/reopen);
the 🎲 re-roll button sits beside the field.
The launch POST already carries this name (VisorApi.launch → /v1/machines/launch);
end-to-end launch is gated on the in-flight /v1/machines gateway route.
tsc clean; vitest 1279/1279 (+3 naming); next build ✓.
A minimal, honest console for the per-org Hanzo PaaS — cloud's native
/v1/platform control plane (hanzoai/cloud clients/platform). A signed-in org
member manages their OWN container apps: list + live status, deploy/stop/start,
source-tagged deployment logs (cloud#75), KMS-sealed env (secret values ALWAYS
masked), and verified custom domains (DNS challenge records + Verify).
DISTINCT from the admin `applications` fleet board (/v1/apps) and from
internal-admin platform.hanzo.ai. Org-scoped by the Bearer owner via the /cloud
bearer proxy (the raw session cookie never reaches cloud-api).
- lib/api/platform-apps.ts — typed plain-REST client for /v1/platform/* over
originV1Url → /cloud proxy (`platform` already allow-listed in proxy-allow.ts;
added to next.config CLOUD_V1_HEADS so /v1/platform/* rewrites to /cloud).
- components/products/PlatformAppsModule.tsx — list + SlideOver detail
(overview/deploy, env masked, domains + verify, source-tagged logs). Honest
states throughout: Loader, EmptyState (create-via-CLI), BackendStateCard for a
/v1 failure — never fabricated rows.
- components/products/platform-apps/logic.ts (+ .test.ts, 9 tests) — pure view
logic; maskedEnvRows ASSERTS a secret's plaintext never renders.
- registry: one new 'app-platform' Compute entry.
Verify: tsc --noEmit clean, vitest 1343/1343, next build ✓ compiled. Authed
visual e2e is post-deploy (console convention).
The same-origin CSRF guard (`sameOriginOK`) was enforced ONLY inside the shared
`forwardWithUserBearer` (the user-bearer proxies: /cloud, /ai, /vm, /commerce,
/cms, /superbase, /tasksd, /admin/aggregate). Every HAND-ROLLED cookie-auth
mutating route lacked it — so a cross-site page carrying the victim's auto-sent
cookie could drive a state change: KMS secret create/rotate/delete (/admin/kms),
PaaS control-plane deploy/scale/delete (/paas), IAM user/org/project mutations
(/admin/iam, /org/iam), billing writes + wallet credit, key mint/revoke, org
onboard, waitlist join, login/logout. `hz_session` is SameSite=Lax, but the
fallback casibase cookie's SameSite is not controlled by the console — so this
defense-in-depth guard is required, not optional.
Decomplected into ONE guard, `csrfRefusal(req, shape)` (co-located with the pure
`sameOriginOK` in bearer-proxy.ts): null on a same-origin request or a safe
method, else a fail-closed 403 in the caller's error envelope. Reads only headers
(never the body), so it composes before any req.text()/json(). `forwardWithUserBearer`
now calls it too — one policy, one place, applied to the WHOLE BFF.
Applied at the top of: forwardIam (→ /admin/iam + /org/iam), /admin/kms, /paas,
/billing/v1, /billing/v1/topup/wallet, /training, /keys, /onboard, /waitlist,
/auth/{session,refresh,signup}.
Also hardened, same "never trust the client" principle:
- /training now server-resolves X-Org-Id via `orgFor` (pins a non-global admin to
their own org) instead of forwarding the raw browser header — matches /paas +
/admin/kms, so a brand admin can't drive another tenant's training jobs even if
the backend trusted the forwarded header.
- /billing/v1/topup/wallet now requires a session (`resolveUser`) and credits the
SERVER-RESOLVED billing subject, never the client-supplied `userId` (which let a
caller credit an arbitrary account); stamps X-Org-Id for correct ledger
namespacing. (Commerce must still dedupe on (network, txHash) — RED handoff.)
Tests: +6 csrfRefusal cases; full suite 1340 passing, tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retire the remaining standalone Next server routes so the console is a pure
static SPA calling same-origin /v1/console/* (served by the go:embed one-binary,
routed through the gateway in the split deploy — one way, both topologies):
- waitlist, embed-status, billing/v1/topup/wallet -> ported to cloud
/v1/console/{waitlist,embed-status,topup/wallet} (real server work: Go
handlers land in hanzoai/cloud).
- keys, onboard -> repointed to the already-merged cloud /v1/console/{keys,
onboard} (completes cloud#74's console side; they were still calling the old
/keys,/onboard handlers, which broke under the static export).
- docs -> a client redirect page (app/docs/page.tsx): a host->docsUrl map the
browser already has (config.docsUrl); no server work, so no handler. Resolves
the brand in an effect (no SSR/CSR hydration mismatch). Replaces the 308
route the static export cannot run.
All calls go through the central client's v1Url() (config.cloudUrl, same-origin),
so the error envelope, cookie creds, and retry/refresh are unchanged. The ported
route.ts handlers are deleted (build:embed already stashed every route.ts; these
simply no longer exist).
Verified: tsc --noEmit clean; vitest 1334/1334 green; `npm run build:embed`
emits the full static out/ (real @hanzo/gui bundle, /docs prerendered).
NOTE: deploy the cloud image carrying the /v1/console/* handlers BEFORE this
console build (the SPA now depends on them). The remaining BFF proxies
(/cloud,/ai,/commerce,/billing catch-alls) are a separate, larger repoint for
full embed functionality and are out of this change's scope.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Replace the external-portal / HUSD-only dead-end with a real card top-up IN the
console. 'Add credits' (BillingOverview, HomeSummary), 'Top up' (SidebarWallet),
and the Billing → Credits tab now open /billing/credits: a Square Web Payments
card form that credits the org's canonical cloud-credit balance — the SAME ledger
the gateway debits for AI usage.
- BillingCredits.tsx: amount picker + Square card iframe (PCI SAQ-A — the PAN is
entered into Square's iframe and tokenized in-browser; we only ever hold the
single-use nonce). Pay → POST /billing/v1/topup/token via the same-origin proxy
(service token + server-pinned subject) → commerce charges + credits the org.
Button locks in-flight (no double-submit); nonce is single-use (no double-charge);
honest states (config-unavailable, decline, success). Sandbox badge + test-card
hint when the deployment is Square sandbox.
- lib/billing/square.ts: typed Web Payments SDK surface, fail-safe env→CDN map
(non-'production' → sandbox tokenizer), pure amount validators, idempotent loader.
- BillingApi.paymentConfig() + topupWithCard() over the existing /billing/v1 proxy.
- HUSD crypto stays a secondary option (link to /wallet); no external billing.hanzo.ai.
Tests: square.test.ts (11) green; tsc strict + next build clean; full suite 1324 green.
next build's strict type-check (onlyShorthandStyleProps) rejects the maxWidth
longhand on a Stack; tsc --noEmit did not surface it. Also relabel the route
honestly (it is a reachable design-reference, not 'not shipped').
(cherry picked from commit 9ce0625ce413e8c3e52eaadddd327c0d2f30a759)
Five "advertised-but-broken" surfaces the live E2E suite flagged, fixed honestly
in the client (no fabrication):
- Vector module rendered nothing: normalizeResourceList validates + unwraps the
provisioning list at the transport boundary (bare array, or a
data/items/results/resources/collections/list/rows wrapper incl. one level of
nesting e.g. Qdrant result.collections), honest [] fallback. A wrapped 200 body
was reaching the list view's for..of and throwing behind the error boundary while
SQL/KV (bare arrays) rendered. ONE place, every kind.
- /chat reply now STREAMS token-by-token via AiApi.ragChatStream (grounded RAG
headers ride PlaygroundApi.streamChat). SSE parser canonical home moved to
lib/api/stream.ts (one definition, re-exported from playground/stream.ts). The
error card's Retry now re-runs the last user turn (was a no-op).
- Functions list self-freshens: useReloadOnFocus refetches on window focus /
tab-visible so an API/CLI-deployed function appears without a reload; + Refresh.
- Sign-out redirects deterministically to /signin after DELETE /auth/session
(AuthGate's reactive redirect could be pre-empted by an in-flight session
re-hydrate, stranding the user on /).
- CRM summary rollup lag is BACKEND (materialized rollup eventual consistency);
the console already refetches /v1/crm/summary after every create/delete —
flagged, NOT faked.
tsc --noEmit clean · vitest 1290/1290 (3 new suites) · next build ok.
Integrates the Basel Grotesk (UI) + Geist Mono (code) typography pass with the
family model-browser blank-row fix. Strict superset of v8.4.52.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Zen family showed two nameless rows: the gateway's meta-routers (router:general,
…) bucket into Zen by provider but carry no display name, and modelDisplayName
returns '' when a record has no name. Fix both: isChatModel now excludes router:*
(a routing policy, not a pickable model — it lives in the Routing tab), and the row
label falls back to the raw id when there's no display name (displayLabel). 21 unit
tests (router exclusion + never-blank label).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Converge the console onto the canonical Hanzo typography without ripping the
Tamagui mechanism:
- Self-host Basel Grotesk (Book 400 + Medium 500) via @font-face in
app/globals.css and override the @hanzo/gui (Tamagui) v5 body + heading font
family to 'Basel' in gui.config.ts, so every Text/Paragraph/H* renders Basel
(one place, whole product). Replaces the default system-font stack.
- Geist Mono for code/data via CDN import + a code/pre/kbd/samp rule.
Sidebar toggle (lucide PanelLeft) + true-black tokens already shipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:49:19 -07:00
843 changed files with 107047 additions and 12575 deletions
/** The REAL 17 infra tiers commerce seeds (models/catalogentry/seed/infra-tiers.json),
* in the raw `catalog-entry` shape the admin GET /v1/catalog/entries returns. */
functionseedEntries():Record<string,unknown>[]{
constcloud=(
[
['cloud-starter','Starter','Get started for free. Perfect for side projects, bots, and learning.',500,{id:'starter',vcpus: 1,memoryGB: 1,diskGB: 20,cpuType:'shared',maxVMs: 1,priceMonthly: 5,features:['1 VM','1 vCPU','1 GB RAM','20 GB SSD'],freeTier: true}],
['cloud-builder','Builder','For developers shipping real products.',1000,{id:'builder',vcpus: 2,memoryGB: 2,diskGB: 40,cpuType:'shared',maxVMs: 5,priceMonthly: 10,features:['Up to 5 VMs','2 vCPU']}],
['cloud-dev','Dev','The sweet spot. Full dev environment with room to grow.',1500,{id:'dev',vcpus: 2,memoryGB: 8,diskGB: 25,cpuType:'shared',maxVMs: 25,priceMonthly: 15,features:['Up to 25 VMs','2 vCPU','8 GB RAM'],popular: true}],
['cloud-turbo','Turbo','4x the power. Browser automation, CI/CD, and heavy workloads.',3900,{id:'turbo',vcpus: 4,memoryGB: 16,diskGB: 160,cpuType:'shared',maxVMs: 25,priceMonthly: 39,features:['4 vCPU','16 GB RAM']}],
['cloud-turbo-dedicated','Turbo Dedicated','All the power of Turbo with dedicated CPU cores.',4900,{id:'turbo-dedicated',vcpus: 4,memoryGB: 16,diskGB: 160,cpuType:'dedicated',maxVMs: 25,priceMonthly: 49,features:['4 dedicated vCPU']}],
['datastore-basic','Basic','For teams getting started with analytics',6652,{id:'basic',replicas: 1,ramGiB: 8,vcpu: 2,storageGB: 1000,priceMonthly: 66.52,priceHourly: 0.0922,support:{level:'standard'},features:['async_inserts','http_api']}],
['datastore-scale','Scale','For production workloads with high availability',49938,{id:'scale',replicas: 2,ramGiB: 8,vcpu: 2,storageGB: null,priceMonthly: 499.38,priceHourly: 0.6936,support:{level:'priority'},popular: true}],
{...base,slug:'dns-basic',name:'DNS Basic',description:'Managed DNS for a domain.',category:'dns',price: 500,priceAnnual: 400,trialPeriodDays: 0,perSeat: false,contactSales: false,popular: false,metadata:{zones: 1}},
// Honest states that count as a truthful render for ANY surface (real content is added
// per-surface). Kept in ONE place so every marker is consistent.
constHONEST=
'Add credits|Your session expired|Access required|Not enabled|Not available on this deployment|initializing|runtime|managed by Hanzo|Connected|Operator access|No .* yet|not connected|not configured|Sign in'
'⚠ no endpoint returned a clean 401/403 — the console backend appears to be mid-roll (5xx). The fail-closed invariant still held (no 2xx data leaked).',
"$comment":"SEED DATA for the platform `package` table — the white-label package catalog. This file is the SOURCE for seeding the platform DB (`~/work/hanzo/platform`), NOT a console runtime import. The console reads packages at runtime via `GET /v1/packages` (the platform serves these rows); adding a package = inserting a row here + reseeding, never a console code edit + redeploy. See `src/lib/api/packages.ts` and the follow-up spec in LLM.md. Each row matches the `Package` shape (`src/components/products/tenants/packages.ts`).",
"packages":[
{
"id":"console-admin",
"name":"Console / Admin",
"description":"A white-label admin console for the tenant — their own branded control plane over Hanzo Cloud.",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.