v8.4.73 moved the git BFF to a top-level /git path (right — /v1/* is gateway-routed
away from Next), but used STATIC route handlers (app/git/accounts, app/git/repos).
Origin verify (pod-direct) showed the (dashboard)/[...slug] catch-all PAGE shadows a
static sibling route handler → /git/accounts served the SPA HTML, not JSON.
Fix: use the PROVEN top-level-BFF shape — a [...path] CATCH-ALL route handler
(app/git/[...path]/route.ts), exactly like /cloud, /ai, /paas, /cms. A catch-all
route handler wins over the catch-all page. One handler dispatches /git/accounts +
/git/repos. Client paths unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live verify of v8.4.72 showed the deploy hub renders in dark Tamagui (hero,
Service/Static/Container target selector, App name/Project composer, Connect-a-Git
card, Start-from-a-template card — all present, no error boundary), but
/v1/git/accounts 404'd: console.hanzo.ai's ingress routes ALL /v1/* to
hanzoai/gateway (bypassing Next), so the /v1/git/* route handlers were shadowed and
hit cloud-api (no such route → 404) — the same class the v8.4.70 CMS fix documents.
Fix: move the git BFF to the top-level Next-served path /git/* (like /cloud, /ai,
/paas, /cms), and point the GitApi client at /git/{accounts,repos}. Verified
/git/accounts reaches Next (x-powered-by: Next.js). The connect-git dropdown now
resolves the IAM-linked GitHub token server-side and lists the user's real repos
(honest "Connect GitHub" when unlinked).
The 403 on /v1/platform/projects is the honest "platform access not provisioned for
this org" path — the hub degrades to new-project mode (no crash), by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring console's deploy-a-new-project flow up to the hanzo.app/new experience: a
single "Deploy something new" hub (Tamagui, true-black dark) that leads with
repo→deploy against the REAL per-org Hanzo PaaS, plus a real connect-git dropdown
and the real template gallery.
- New "Deploy" entry (id: new) at the top of the Platform category → DeployHub.
- Composer: paste a Git repo URL (or image ref) + target selector — Service
(git→build→run), Static site (git→static), Container (image→run). Each maps to a
real PaasApi.createApp shape (buildType nixpacks/static/image); no invented targets.
Deploy watches Queued→Building→Deploying→Live on the live RailwayDeploy pipeline.
- Connect-git dropdown is REAL: ports hanzo.app's /v1/git/{accounts,repos} BFF into
console (resolves the IAM-linked GitHub token server-side via the cloud session
cookie; token never reaches the browser). Honest "Connect GitHub" CTA when unlinked.
- Templates: real /v1/templates gallery with "Open in builder" (hanzo.app deep-link)
and "Browse all" → in-console /templates.
- DRY: one deploy orchestration (paas/deploy.ts launchDeploy) shared by the hub and
the Applications New-app form; pure per-target mapping in paas/logic.ts (unit-tested).
- 33 unit tests (logic targets/detectors + launchDeploy order + git relativeTime); tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Release: the Models module is now the unified, family-grouped model browser at
chat parity (Zen first + Qwen/Meta Llama/DeepSeek/Mistral/Google Gemma/OpenAI
GPT-OSS), true-black surface-depth ladder, skeleton loading, tabular numerals
across all metric cards + the model browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CTO design bar (Linear/Vercel/Stripe): elevate the flagship model surface and the
whole shell's depth.
- Surface depth ladder over true-black: $color1 #050505 (resting panels), $color2
#0a0a0a, $color3/$color4 #171717/#1f1f1f (interactive/elevated). Cards now read
with real depth over the #000 canvas instead of flat black — applied globally, so
every panel (agents, metrics, cards) gains the same layering. Text-contrast scale
($color10–12) untouched.
- Model browser: designed skeleton loading (shimmer family cards, no spinner),
staggered fade-in entrance (40ms), tabular numerals on every numeric column
(context / $-per-Mtok / counts / stats) so figures align, hairline stat dividers,
tighter type scale (family $5/800, stat $7/800 -0.5 tracking, uppercase labels),
crisp hover rows, and a proper icon empty state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The <html> inline background and viewport themeColor were still #0a0a0a — a hair
off the true-black the .t_dark CSS override paints. Match them to #000000 so the
very first paint (before CSS) and the mobile browser chrome are pure black too,
consistent with hanzo.ai + hanzo.chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat 444-row catalog table with a family-grouped browser matching
hanzo.chat's picker exactly: collapsible sections per family — Zen first (with
zen5-mini flagged Default), then Qwen · Meta Llama · DeepSeek · Mistral · Google
Gemma · OpenAI GPT-OSS — each nesting its current-gen chat models with real
context, $/Mtok price, and live-vs-catalog availability. Click a model for the
full specs/pricing/features detail panel (unchanged). Search filters across every
family; a stats strip shows families / models / available-now. Reuses ProviderLogo
+ formatters; grouping is the pure, unit-tested groupByFamily. One console home for
model selection, the same families the user sees in chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unified model browser groups the live catalog into hanzo.chat's exact 7
families — Zen (house brand) first, then Qwen · Meta Llama · DeepSeek · Mistral ·
Google Gemma · OpenAI GPT-OSS. Data-driven from /v1/pricing/models joined with
/v1/models: provider-defined families match the provider string; the named slices
(Gemma ⊂ Google, GPT-OSS ⊂ OpenAI) match an id slice, so provider-grouping's
Gemini/GPT-5 noise and the HuggingFace hub mirror stay out. Current-gen chat only
(drops zen4/qwen2 sunset gens + embedding/rerank/tts/asr/image/guard modalities +
:free dup aliases). Empty families are dropped — honest to what the gateway serves.
fetchCatalog now merges live-only models the older pricing bundle omits (the
current Zen set: zen5-flash/coder/nano-*), deduped by id and name, marked Available.
Pure + unit-tested (19 cases): chat-exact curation, distill disambiguation, slice
matching, sunset filtering, Zen-first ordering, zen5-mini default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`output: 'export'` prerenders every page THROUGH app/layout.tsx, whose
generateMetadata reads the Host header (next/headers `headers()`) to brand the
SSR <title> per host. A static export has no request, so that request-time read
throws in the Server Components render for ALL pages — the export aborted on
/_not-found (and /signin), so `npm run build:embed` emitted no out/ and the
hanzoai/cloud one-binary silently shipped the fallback shell instead of the real
console.
build-embed.mjs now, for the export ONLY, drops the layout's next/headers import
and resolves the host to undefined (→ the build-time default brand; the embed is
same-origin and re-resolves the real brand client-side from window.location),
then restores the pristine layout in the finally. The normal `npm run build`
(server build, per-host SSR <title>) is untouched.
Verified: build:embed now emits out/ (index.html ~369 KB, /_next/static
assets); app/layout.tsx is restored to pristine after the run.
* feat(console): admin AI-provider control board — enable/disable + set-primary over gated /v1/admin/providers
Adds the platform-wide provider MANAGEMENT dashboard (admin.hanzo.ai) for the
shared-gateway upstream providers (do-ai, openrouter, fireworks, openai-direct,
zen): one row per provider with a working Enabled toggle, a Primary badge +
Make-primary action, model count, a key present/missing pill (NEVER the key),
and an honest DERIVED health verdict (enabled+keyPresent=Ready, enabled+no-key=
No key, disabled=Off — labeled derived, not a live probe). DISTINCT from the
customer 'providers' catalog entry (model catalog + BYOK per-org CRUD).
Server: 'providers' added to the global-admin-gated admin-aggregate heads
(ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS); the aggregate route now
exports POST (same getAdminGate fail-closed 403 + same-origin CSRF the shared
forwardWithUserBearer already enforces on any mutating method — no new trust
boundary). New originPost in client.ts pins the mutation to the console's OWN
origin so a split-origin NEXT_PUBLIC_CLOUD_URL can't route around the gate.
Client: typed ProviderAdminApi (list/toggle/setPrimary) + optional-safe
normalizers + a pure deriveHealth. keyPresent is strict-true (fail-closed — a
provider key is never modeled or surfaced).
Integration flag: disabling OpenRouter here also gates it out of the pricing
catalog (the DO-first ENABLE_OPENROUTER sync reads provider-enabled state).
Tests: provider-admin.test.ts (deriveHealth matrix, normalizer key-leak
safety, same-origin /v1/admin/providers URL shape for GET+POST, never the
cloud host) + admin-aggregate providers head. tsc --noEmit clean; vitest
1179/1179 (97 files); next build clean (17/17, /admin/aggregate + /[...slug]
registered). Authenticated visual e2e is post-deploy.
* fix(console): admin-aggregate proxy targets cloud /v1/admin/* (integration-path fix)
The console admin-aggregate proxy (app/admin/aggregate/[...path]/route.ts) rebuilt
the upstream path as `admin/<head>` and forwarded it verbatim to CLOUD_API_URL (an
origin, no /v1), so a browser call to /v1/admin/providers hit
cloud-api.hanzo.svc:8000/admin/providers. But cloud serves EVERY admin route under
/v1/admin/* (hanzoai/ai's `/v1/*` beego glob for /v1/admin/providers{,/toggle,/primary};
cloud's own clients/admin `app.Get("/v1/admin/{overview,finance,compute,...}")`). There
is no bare /admin/* route → the provider dashboard's list/toggle/primary all 404'd.
(The overview/finance/compute boards masked the same mis-path behind LivingOverview's
honest usage-ledger fallback; provider-admin has no fallback, so it was visibly broken.)
Fix (server-side upstream path only; the browser-facing clean /v1/admin/* is unchanged):
- route.ts: build `v1/admin/<head>` (was `admin/<head>`) so the verbatim forward lands
on cloud's real /v1/admin/* route. The rewrite destination (/admin/aggregate/<head>)
is the internal Next route and correctly carries no /v1/ — this handler adds it.
- admin-aggregate.ts allowAdminSurface: validate the exact forwarded shape
`v1/admin/<allowed-head>` (segs[0]==='v1' && segs[1]==='admin' && ALLOWED.has(segs[2])),
refusing v1/admin/iam, v1/admin/kms, bare v1/admin, the pre-fix bare admin/<head>, and
every traversal. The two-layer pathIsClean + allow-list defense (raw AND WHATWG-normalized
path) is intact on the new shape: v1/admin/providers/../iam is refused at layer 1 (literal
..) and its normalized form v1/admin/iam at layer 2 (iam not allowed).
Beneficial side-effect: overview/finance/compute/orgs/audit/products/usage now also target
/v1/admin/* correctly (all shared this one proxy). next.config.mjs is untouched — its rewrite
already fires for every ADMIN_V1_HEAD incl providers, GET and POST.
Also (RED LOW-1): ProviderAdminModule toggle no longer flips the row optimistically before
the server confirms — a slow 403 never briefly renders an unauthorized 'on'; the enabled
state changes ONLY on a 2xx (the switch is disabled via `busy` in flight).
Tests: admin-aggregate.test.ts rewritten to the v1/admin/<head> shape (+ refuses the
pre-fix bare admin/<head>); bearer-proxy.test.ts +7 end-to-end forward tests proving the
upstream URL is cloud/v1/admin/providers (not /admin/providers), GET+POST forward, and
traversal / iam / kms 404 without ever fetching. tsc --noEmit clean; vitest 1187/1187
(97 files); next build ✓ (/admin/aggregate/[...path] registered).
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Bump past the live v8.4.50 so the true-black change ships as a clean immutable
semver tag (SEMVER-only build; a branch build without a bump collides with an
existing tag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New 'Service Map' product (Observe) over the real o11y/SigNoz APM controllers
via the same-origin /cloud user-bearer proxy: /v1/o11y/v1/services (RED per
service), /dependency_graph (call edges), /service/top_operations. Every KPI/
row/edge folds over what the runtime returned — honest empty/RuntimeNotice on
503/404/403/401, never fabricated APM data. Tap a service row → detail rail
(RED overview, top operations, up/downstream deps).
- lib/api/apm.ts: ApmApi client + normalizers (services/deps/ops/hosts/pods/
nodes/exceptions/dashboards) with a stable ApmWindow.
- observability/apm-format.ts: pure RED/number/duration formatters.
- ServiceMapModule.tsx: the mounted module; wired into registry as 'service-map'.
- index.ts: barrel exports (apm NodeRow aliased ApmNodeRow — distinct from the
blockchain nodes.ts NodeRow).
Verified: 35/35 tests pass, tsc --noEmit clean (0 errors).
Override the @hanzo/gui (Tamagui) .t_dark base --background to pure #000 so the
console reads as ONE black brand with the marketing site (--background:#000)
and hanzo.chat's OLED .dark theme. Panels sit a hair above pure black (#050505
press / #171717 hover-elevated) for depth. defaultTheme was already dark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refine the console toward a quiet, spacious, low-glare feel that's healthy to
work in for a full day (Linear-esque FEEL, our own @hanzo/gui tokens).
Calm tokens (app/globals.css, scoped html:root.t_dark/.t_light so they win over
the generated @hanzo/gui theme on specificity — DRY, whole app calms at once):
- softer cool-charcoal surfaces with gentle elevation STEPS so cards read as
calm panels, not flat voids on pure black
- off-white primary text (no pure-#fff glare) + muted secondary steps
- low-contrast hairline borders; comfortable body line-height + antialiasing
Sidebar (DashboardShell):
- calm neutral section headers (Linear-style) instead of a saturated rainbow;
per-product COLOR now lives only on the icons (restrained accent)
- softer active state ($color4, not the loud $color5 block)
- more breathing room: taller category headers, larger row + section gaps,
responsive rail padding (collapsed vs expanded)
Big desktop: content is a centered, capped column (max 1680) with padding that
scales up at xl — wide screens read comfortably, not stretched full-bleed.
Responsive:
- PageHeader wraps (flex + minW) so long subtitles wrap on mobile instead of
running off-screen; actions drop below the title when narrow (DRY, every page)
- Inference "Usage Overview" overlap fixed: MetricStat no longer flex-collapses
(flex-basis:0 in an auto-height column made the label overlap the value)
typecheck 0 errors · 1167 tests · next build green.
The Model Catalog rows already open a rich detail pane, but the detail's primary
action pushed a bare /playground and DROPPED the model the user was looking at — it
looked wired but lost the selection. Now it deep-links /playground?p=<share> carrying
that model, so the Playground opens PRESELECTED on it (the composer restores the model
from the ?p= share state it already reads on mount). Same fix applied to the
Marketplace 'Try' CTA — both reuse ONE pure helper.
- New pure playgroundPathForModel(modelId) in playground/share.ts — the ONE way to
deep-link the Playground onto a model (URI-safe; empty prompt, default settings).
- ModelCatalogModule detail: 'Open in Playground' → prominent 'Try in Playground'
(theme=light) via the helper; routing/copy reuse the derived modelId.
- MarketplaceModule.open(): available model → playgroundPathForModel(id) (was bare
/playground); catalog-only → /models. DRY, one deep-link path.
tsc clean; +2 share tests (round-trip + slash/space id), marketplace 16/16.
Base records (RecordsModule) now render the @hanzo/data RecordsView — table <-> board,
filter/sort/group, inline cell edit, kanban drag — over REAL per-org Base data via the
/superbase proxy (new base-data/CollectionView; inline edits + board moves persist through
BaseDataApi.updateRecord, honest error banner). CollectionTable superseded (one way).
RecordDetailView gains a titled detail panel; DnsModule onRowPress -> onOpen (new DataTable API).
CRM = Base views: CrmModule renders companies / contacts / opportunities through the SAME
RecordsView — CRM entities expressed as @hanzo/data FieldDefinition schemas + pure record
mappers (crm/collections: money->currency, epoch-s->ms, companyId->named relation chip);
opportunities get a stage PIPELINE board; company relation options injected for filter.
Live create stays; delete preserved + upgraded to bulk-delete via row selection. Read-only
views over live data (no /v1/crm update endpoint yet) — honest by construction.
@hanzo/data 1.1.0 -> ^1.2.0. Rebased on latest main (8.4.49 -> 8.4.50).
tsc clean; vitest 1198 green (+ crm/collections); next build green.
The customer GPU catalog rendered as non-clickable rate rows (Overview 'Popular
accelerators', the GPUs-tab 'GPU catalog', and the Pricing tab) — real live visor
data, but it read like a static brochure. Now every accelerator row is TAP-TO-ACT:
tapping one opens the shared LaunchDrawer (kind=gpu) preselected on that accelerator's
size slug (the drawer already supports initialSize), so the price you see is the price
you launch. Same pattern as machines PR#57's MachineCatalog → onLaunch.
- launch() now takes an optional initialSize (memoized); launchGpu(row) = launch(row.slug).
- onRowPress wired on all three catalog DataTables + the Pricing tab (new optional onLaunch prop).
- Header / empty-state / settings 'Launch' buttons fixed to () => launch() (no event-as-size).
- Honest copy: 'tap to launch' hints; no fabricated data (empty catalog still honest).
tsc clean; gpus vitest 10/10.
Two surfaces, both over the ONE /v1 surface with real data + honest states,
reusing the existing LivingOverview + design system (DRY, no new UI systems).
Surface 1 — admin.hanzo.ai "Overlord" overview (god-view of EVERYTHING):
- New living-overview config `overlord` + pure adapter `fromOverlord` composing
THREE real sources: the operator inventory (PlatformApi.apps → the platform-wide
PRODUCT HEALTH board + product/healthy/needs-attention counts + distinct-org
count — the centerpiece), the all-orgs `/v1/admin/overview` aggregate
(usage/spend/top-models/activity/alerts) when routed, and the real commerce
usage ledger (all-orgs) as the honest fallback so the board is never blank.
- New `overlord` catalog entry (Observe, admin:true) rendered by the ONE
LivingOverview. GLOBAL-ADMIN ONLY: hidden from every customer's nav/launcher/
palette (visibleCatalog filters admin entries), the catch-all shows the managed
notice for a non-admin, and `/v1/admin/overview` is server-gated by getAdminGate.
- Pure health-tally helpers (`healthTally`, `orgsFromApps`) — every product-count
KPI is derived from the real inventory, never fabricated (empty → honest em-dash).
Surface 2 — Web Search + Crawl product panel (SearXNG + Crawl4AI, LIVE):
- New `WebSearchApi` (lib/api/websearch.ts) over cloud `/v1/websearch/*`; search
wired same-origin prefix-free `/v1/websearch/search` → the hardened `/cloud`
user-bearer proxy (added `websearch` to CLOUD_HEADS + CLOUD_V1_HEADS — minimal,
additive; distinct arrays from the concurrent providers lane).
- New tabbed `SearchModule` (Overview · Try Search · API · Engines · Config) —
a REAL live search box, honest live-probe health (no health endpoint exists),
the two endpoints + copy-paste curl, the deployed engine set (read-only), and
the honest deployed config. HONEST GAPS surfaced, not hidden: usage is not
metered yet (no cloud_usage rows for websearch), and scrape is documented but
NOT a live try-it (it needs the shared WEBSEARCH_API_KEY, not a user session —
so the console can never drive a scrape; no secret is ever exposed).
- The `websearch` + `crawl` catalog entries render the one module (crawl upgraded
from a native-overview stub — one product, cross-linked, no duplicate surface).
Tab slugs are non-base (search/api/engines/config) so they never collide with
the shared Settings/Status/Logs/Metrics per-product sub-pages.
Verification: tsc --noEmit clean; vitest 1191/1191 (98 files; +28: 7 Overlord
adapter, 8 websearch normalizers, 9 search logic, 1 websearch allow-list, +3
registry-consistency now covering the new configs); next build ✓ (all routes;
/overlord + /websearch/* + /crawl all resolve 200 on the dev server, catch-all
compiles clean). Live authenticated visual e2e (admin session) is post-deploy.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
- SidebarWallet balance row: router.push('/cost') → '/billing' (no 'cost'
module in the registry; billing is the canonical balance/spend surface).
- HomeSummary 'View cost' button: same /cost → /billing fix.
- Embeddings SettingsView docs button: docsUrl/embeddings → docsUrl/docs/embeddings
(Fumadocs serves only under /docs/*; matches the docsUrl() /docs convention).
Also updated the stale SidebarWallet docstring that referenced the removed /cost.
The document <title> is SSR metadata resolved from the build-time default host,
so console.lux.cloud / console.zoo.cloud tabs read 'Hanzo Cloud Console' — a
white-label violation (Hanzo name on a Lux/Zoo surface). Read the request Host
header in generateMetadata and resolve the brand per host, so the tab title is
'Lux Cloud Console' / 'Zoo Cloud Console'. The visible shell was already correct
(client resolves brand from window.location); only the SSR title leaked.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Gap 1 (console self-serve domains UI) over the live /v1/platform/.../domains
surface (via the /cloud bearer proxy, org from the Bearer owner):
- lib/api/paas.ts: PaasDomain + listDomains/addDomain/verifyDomain/removeDomain.
- DomainsPanel.tsx: a Domains section on the app detail — lists the default host,
org-subtree hosts, and BYO custom domains with an HONEST per-domain status from
the operator CR (live / provisioning / awaiting deploy / unverified, never
fabricated); an Add-domain form; per-domain Remove; and — for a pending custom
domain — the exact DNS records to publish (TXT + CNAME, with copy) plus a Verify
action that flips it live with TLS. Default host is non-removable.
- paas/logic.ts (+tests): pure isPendingCustom / canRemoveDomain / domainStatusLabel
/ orderDomains. Wired into PaasApplications AppDetail.
Drive-by: fix a pre-existing origin/main tsc error in overview/living/
open-edition.test.ts (vi.fn<[Args],Return> → single-function-type form the
installed vitest requires) so `tsc --noEmit` is green. Not caused by / related to
this feature; next build (which excludes tests) never surfaced it.
Verify: tsc --noEmit clean; vitest 1167/1167 (+12 domain logic); next build ✓.
The hanzoai/cloud Go binary go:embeds the console SPA and serves it at its own
web root; the Dockerfile already looks for `npm run build:embed` to produce the
static bundle. This adds that target — the last piece of 'True 1-binary FE'.
- next.config.mjs: CONSOLE_EMBED=1 → output:'export' + images.unoptimized and
DROPS the aiSurfaceRewrites (a static export cannot run rewrites, and does not
need them: the clean /v1/<head> the SPA already builds terminates directly at
the embedded cloud's mounted /v1 subsystems, which is what the rewrites used to
forward to via the Next BFF). The normal `npm run build` (server build) is
UNCHANGED, so the standalone console deployment never regresses.
- scripts/build-embed.mjs: prepares the app for output:'export' for ONE build and
ALWAYS restores it (finally block; main stays byte-identical):
(1) stashes every app/**route.ts — a static export has no runtime for a
server route handler; the BFF proxies collapse to cloud /v1/* and the two
standalone routes (keys/onboard) are ported to cloud /v1/console/*.
(2) overlays the two dynamic client pages ([...slug], discover/[id]) with a
force-static server wrapper (generateStaticParams + dynamicParams=false)
so they satisfy output:'export'; the real client body still ships in the
JS bundle (client nav hydrates it) and deep links hit the host's SPA
fallback (cloud/webui.go serveIndex → index.html) which re-resolves them.
- build:embed npm script.
Proven: `npm run build:embed` → out/ (47 files, 6 html incl. discover/ + the
SPA-shell index.html at 376 KB); working tree restored clean after.
The whole-repo sweep after v8.4.42 caught the one remaining user-facing Base
competitor reference: the built-in assistant's system prompt described Base as a
'Firebase-style backend' (from the v8.4.43 grounded-assistant lane). Rewrote it in
Hanzo's own voice — 'a realtime backend — spin up per-org Bases with content types,
records, and auth'. The console is now free of Supabase/Firebase user-facing copy.
Drive-by: open-edition.test.ts (#60) used the old vitest vi.fn<[Args],Return>() form
that vitest v3.2.4 rejects — main's tsc was RED. Migrated to the v3 single-fn-type
form. tsc clean, vitest 1163/1163, next build green.
Add the Open Edition / run-for-pay overview as a living-overview config
(add-a-product = 1 config) that renders from the REAL commerce usage
ledger scoped to the `open-edition` product tag. Spend billed = the
served-revenue figure R (cost + 25% resell margin) per the pricing spec.
- living/registry.ts: `openEditionOverview` config + `open-edition` map
key. Reuses UsageApi.overview({ product: 'open-edition' }) + the
existing fromCloudUsage adapter (DRY) — honest-empty when no run-for-pay
usage, never a mock.
- products/registry.tsx: `livingOverviewModule('open-edition')` + a
catalog entry (Observe category) → the board lives at /open-edition.
- living/open-edition.test.ts: behavioral proof the loader forwards the
`open-edition` product scope, maps real data through fromCloudUsage,
and rolls up honest-empty (4 tests).
- e2e/open-edition.spec.ts: dedicated Playwright visual gate asserting the
run-for-pay header, the cost+25% Spend KPI caption, and the spend/tokens
KPIs render; captures a full-page screenshot.
- e2e/pages.spec.ts: add `open-edition` to the all-pages screenshot sweep.
Canonical model: universe docs/architecture/run-for-pay-pricing.md (price)
+ run-for-pay-and-contributor-revenue.md (settlement).
Additive only (no deletions); does not touch the concurrent session's
staged adapters.ts / registry.test.ts.
Make every product's shared base sub-pages real, correct, and per-product via ONE
metadata source, no fabrication.
- Metrics: consolidate the ledger-scope decision to sources.ts metricsScopeFor (kill
the dead MetricsFeed/O11Y_METRICS_PRODUCTS dup); every product filters by
metadata.product===id (honest-empty, never the org total); the inference surface
(inference/models/api/gateway) reads the whole ledger with an explicit honest
'org-wide inference' banner + scope-aware subtitle.
- Status/Logs: verified SERVICE_OVERRIDE (models, bot->bot-gateway, helpdesk->help)
grounded in the live /v1/apps inventory; fix console spec service console2->console;
neutral+honest 'no operator row' copy; Logs-specific honest managed card.
- Settings: settingsConfigFor surfaces real product-specific config (reuse the overview
spec facts+actions, else category default) — a real Configuration card, not a dead form.
- Overview: console spec health repointed; defaultSpec stays honest.
Only subpage/*, overview/*, overview/living/* + per-product metadata. Did NOT touch
AgentsModule/agents/* or assistant/*. tsc clean; vitest 1140/1140; next build green.
Make the console's built-in chat assistant a genuine expert on the whole Hanzo
suite, GROUNDED in real sources (the product registry + docs RAG), never
hallucinated — ONE shared system prompt across all three chat surfaces.
- src/lib/assistant/: decomplected into a PURE builder (prompt-content.ts,
unit-tested) + a thin registry-bound wrapper (system-prompt.ts). Catalog
section generated FROM the live registry (visibleCatalogByCategory) — all
products, per-category, name+description+GCP-analog+deep-link; complete,
current, brand-white-labeled, admin-gated for customers. Plus a curated
accurate 'what Hanzo is' overview + an honest behavior contract (never invent;
say so when Hanzo lacks a thing).
- Wired DRY into ChatConversation (=> FloatingChat bubble + full /chat page) via
AiApi.ragChat, and CommandPalette >/? via commandBarSystemPrompt (base + NAV
contract) — replaces the old nav-only prompt.
- AiApi.ragChat gains optional history (backward-compatible) for grounded
multi-turn; X-Retrieval store 'docs' shared, degrades gracefully.
tsc clean; npm test 1142/1142 (+10); next build green. Rebased on v8.4.42.
Base was a content-type dashboard over the SuperBase orchestrator's own
collections (contacts/tenants/users), so the org saw one shared Base and had no
way to make another — 'stuck with a single base'. Rework it into the Base
INSTANCES manager over the real tenants API (each row = a Base on its own
<slug>.base.hanzo.ai), so the user can SEE all their Bases, CREATE a new one, and
CONFIGURE one (name/size/status/delete). One Base binding — the /superbase proxy
(user bearer + X-Org-Id from the JWT owner, derive-once). Clean split from
Records (which browses a Base's collections + records).
- NEW lib/base-data/tenants.ts (BaseTenantsApi over the tenants collection)
- NEW base/bases-logic.ts (+test): slug/validate/size presets/status
- NEW base/BasesManager.tsx: list + New Base + configure
- BaseModule -> BasesManager; registry routes ''|new|:base
- Drop the Supabase/Firebase copy + gcp:'Firebase' from the Base entry
- Remove the superseded content-type dashboard (BaseDashboard/CollectionBuilder/logic)
Copy: no competitor names in the Base UI or docstrings.
Extend the admin compute-analytics boards from bot/machine to the full
kind spectrum so DOKS clusters, node pools and Functions surface in
admin.hanzo.ai like Bots/Machines.
- admin-compute.ts: widen ComputeKind to bot|machine|cluster|nodepool|
function; asKind now canonicalizes over the whole spectrum (fallback
machine, mirror of visor CanonicalKind) so a cluster/nodepool/function
row never pollutes a sibling board via keepKind.
- ComputeModule.tsx: KIND_UI entries for cluster/nodepool/function;
ClustersModule (kind=cluster) + FunctionsModule (kind=function) thin
wrappers over the ONE ComputeBoard, mirroring Bots/Machines.
- registry.tsx: register cluster-fleet + function-fleet (admin:true,
category Observe), distinct from the customer clusters/functions
products (same split as vms vs machines).
- admin-compute.test.ts: cover the widened spectrum (cluster/nodepool/
function fold + kind-filtering, unknown-kind fallback).
Honest-empty until the emitters + cloud read land. DRY: one ComputeBoard,
one datastore aggregate, one kind canonicalizer.
- AuthzModule: off dead /paas onto /cloud user-bearer proxy → cloud authz
subsystem (hanzoai/authz) GET /v1/authz/policies; per-org enforcer picked
from the Bearer-derived X-Org-Id. Match FE type to the Casbin [sub,obj,act]
tuple (effect always allow); honest-empty + PlatformStateCard on failure.
- SettlementModule: off dead /paas onto the existing /billing/v1 commerce
proxy → GET /v1/billing/payouts (ListPayouts), org-scoped server-side.
Match FE type to commerce payoutResponse (amount cents/currency, status,
destinationType/Id, created). 501 when COMMERCE_TOKEN unset → honest card.
- authz head already admitted in proxy-allow CLOUD_HEADS + next.config
CLOUD_V1_HEADS.
Repoint the last two "not connected" chain-data pages off the admin
/paas proxy onto the native cloud user-bearer proxy, backed by the new
cloud clients/graph subsystem:
- IndexerModule: restGet(paas('indexers')) -> cloudProxyV1Url('indexers')
- OraclesModule: restGet(paas('oracles')) -> cloudProxyV1Url('oracles')
Admit both heads on the /cloud proxy: add indexers,oracles to
next.config.mjs CLOUD_V1_HEADS and proxy-allow.ts CLOUD_HEADS. Bump
8.4.39 -> 8.4.40 so a fresh image builds.
Alerts: restGet('/paas/alerts') -> cloudProxyV1Url('o11y/v1/rules'), the real
hanzoai/o11y alert-rule-states route (cloud mounts /v1/o11y/*, reverse-proxies to
the o11y Deployment which rewrites /v1/o11y/* -> /api/v1/rules; listRules ->
ListRuleStates). Normalize the flattened GettableRule envelope
{status,data:{rules:[…]}} to the flat Alert row: name<-alert, severity<-labels.
severity, status<-state (or 'disabled'), condition<-description. lastFired left
empty honestly (rule-list carries no last-fired timestamp).
Logs: kept honest. o11y's GET /v1/o11y/v1/logs is a hardcoded empty stub and the
only real log read is a composite POST query_range — no clean logs-list route to
repoint to. Copy now names the true gap (needs a real logs-list route) instead of
the stale "VictoriaLogs not deployed"; nothing fabricated.
Status: unchanged — already on native VictoriaMetrics up{} via /telemetry (real
data). Repointing to platform apps would regress to a different, empty concern.
Heads: add 'o11y' to next.config CLOUD_V1_HEADS + server/proxy-allow CLOUD_HEADS
so /cloud admits the o11y sub-surface.
Theme (the real apply): v8.4.36's accent used a CSS class (hz-accent-fill) on the
accent surfaces, but Tamagui does NOT forward className to a Button's DOM node
(only to Stacks), so the org's saved brand color never recolored anything. Rebuilt
the mechanism to be Tamagui-native and verified LIVE: src/lib/theme/accent.ts now
holds the resolved accent in a tiny external store (setOrgAccent, called on load by
OrgAccentProvider AND on save by SettingsModule) exposed via useAccent(); the genuine
accent surfaces (PrimaryButton, the active GPU/Settings tabs, the active sidebar nav
item) recolor with real @hanzo/gui props — inline bg + readable contrast text (light
accent -> black text, dark -> white), the nav via an accent left-bar — reverting to
the default monochrome when the org disables its theme or the hex is invalid.
Verified in a browser: enable green -> primary button green + white text; yellow ->
black text; disable -> reverts (no inline style). Dead globals.css accent block +
classNames removed.
GPUs Pools tab: now honest-empty 'No GPU node pools yet' whenever the org has no
reachable GPU clusters (none provisioned, or the native /cloud/v1/clusters endpoint
isn't live) — a pools-specific state distinct from the Clusters tab, never a
fabricated pool. (Pools are derived from the org's real clusters.)
Rebased on v8.4.37 (Inference dashboard) — strict superset. tsc clean; vitest
1132/1132 (+2 accentFor); next build green.
Redesign the Inference page to the endpoints-dashboard mockup, wired to REAL sources
(honest '—'/empty where unexposed — never the mockup's placeholder numbers). Sidebar +
topbar untouched; only the Inference module content + the shared Metrics sub-page changed.
- Endpoints = managed model catalog (/v1/models) merged with the org's deployed KServe
InferenceServices (cloud /v1/ml/models; new 'ml' /cloud head in proxy-allow + rewrite).
Per-endpoint Requests(24h) + trend sparkline = REAL usage ledger by model id; KServe phase
from live status.conditions; P95/uptime honest '—'.
- Hero 'Connected to Hanzo Cloud' (honest managed copy + purple SVG accent), purple Deploy
Endpoint CTA (real POST /v1/ml/models), right rail Usage Overview (real ledger window +
prior-period deltas) + Quick Actions + Need help (real routes/links).
- Inference OWNS Status + Logs as :tab views (health board + real recorded inference
activity), declared as specific subpages so the router renders them.
- Shared per-product Metrics -> product-parameterized LivingOverview over the real usage
ledger scoped by metadata.product; new byStatus + tokens breakdowns. P95 honest '—'.
tsc clean; vitest 1130/1130; next build green. Rebased on origin/main (v8.4.36) -> v8.4.37.
GPU tabs (the customer bug): a non-admin's GPU page rendered the same static
catalog for every sub-tab (Clusters/Pools/Pricing/Alerts all showed the GPU
catalog) because CustomerGpus was one static component with no tab bar. It is now
TABBED like AdminGpus — the shared GpuTabBar + GPU_TABS (one source of truth,
router.push navigation), active tab from params.tab — with DISTINCT, customer-
scoped, per-org content per tab:
- Overview/GPUs: visor catalog (/vm /v1/gpus) + the org's own GPU machines.
- Clusters: the org's own clusters (PlatformApi.listClusters -> user-bearer
/cloud/v1/clusters), honest-empty; reuses ClustersTab (DRY).
- Pools: GPU node pools derived from the org's real clusters (honest-empty).
- Pricing: the REAL per-accelerator price list from the live visor catalog.
- Alerts: real alerts derived from the org's own GPU machines' health; reuses
AlertsTab (DRY), honest-empty when healthy.
- Settings: honest per-org GPU settings + real counts.
AdminGpus behavior is unchanged (now shares GpuTabBar/GPU_TABS). Pure derivations
(gpuPoolsFromClusters/gpuAlertsFromMachines/catalog stats) are unit-tested — real
per-org data or honest-empty, never fabricated.
Theme accent (folded in): the org's saved brand color (themeData.colorPrimary +
isEnabled) was persisted but never applied. Added src/lib/theme/accent.ts
(applyOrgAccent -> one root --hz-accent CSS var + data-hz-accent) applied on load
(OrgAccentProvider) AND immediately on save (SettingsModule). Genuine accent
surfaces read the one var via hz-accent-fill/hz-accent-bar (PrimaryButton, active
sidebar nav, active tabs) — DRY, reverts to default when disabled/invalid.
tsc clean; vitest 1092/1092 (+16 new); next build green.
Each level-1 CATEGORY is now a collapsible section: the header is a clickable
button with an obvious rotating chevron (▸ collapsed, ▾ expanded), keyboard-
toggleable + aria-expanded, over its product rows revealed in order when open.
The ~120-item flat list condenses to 13 tidy topic headers so the nav stops
overwhelming the user.
- Pure model: src/lib/products/nav-accordion.ts (categoryIsOpen/toggleCategory,
10 unit tests). Default COLLAPSED for most; the active route's category is
always open (navigating reveals it, even if collapsed); filtering opens every
matching group so search is never hidden; clearing search restores collapse.
- Persisted per-user via usePreferences (account-backed + localStorage cache,
the existing sidebarCollapsed idiom) under navCategoriesOpen — survives reloads
+ navigations, never clobbers other choices.
- CategorySection in DashboardShell; body animates height (grid-rows 0fr<->1fr) +
opacity via .hz-acc, chevron rotates via .hz-chevron, both reduced-motion-
guarded; collapsed body is inert (out of tab order). Shared by the desktop
sidebar AND the mobile drawer (one SidebarNav). Items/icons/colors/routes/
active-state unchanged; only the grouping is now collapsible.
tsc 0 errors, vitest 1086/1086 (+10 nav-accordion), next build green.
P0 [CRITICAL]: v8.4.33 resilientFetch called the global fetch as a METHOD
(deps.doFetch(url,init) → this=deps) → the browser threw 'Failed to execute fetch
on Window: Illegal invocation' on EVERY cloud/BFF call — the whole API layer broke
(Analytics/Models/CRM/CMS/... all 'Could not reach the backend'). The client-retry
unit tests passed because they injected a MOCK doFetch (no this requirement) — the
exact class a mock hides. Fixed: destructure const doFetch = deps.doFetch and call it
BARE (this=undefined) — works for a raw global fetch AND a wrapped one. New regression
test simulates a global-only fetch (throws unless this is the global) and asserts the
bare invocation. Live console was rolled back to v8.4.31 on detection; this is the fix.
LESSON: verify a shared-fetch refactor by RENDERING a live data page, not just unit tests.
live-shape (caught by the same live pass):
- CMS: Payload SQLite INTEGER ids ({id:3}) were read as strings → id='' (rowKey
collisions); number-aware idStr. Media bytes url carries ?prefix=<tenant> that the
filename-reconstruction dropped → cmsMediaSrc proxies the doc's real url through /cms.
- Commerce: /v1/store/current wraps the record as {store:{}} → currentStore unwraps .store.
RED LOW-1: /erp allow-list pinned to EXACTLY {Account, Item, Sales Order} (was any
DocType) so an entitled brand member can't over-read User/Salary Slip/etc. through the
shared ERP_API_TOKEN. RED verdict on v8.4.33: 0 crit/high/med, cross-tenant isolation
SOUND across CMS/ERP/Help/Analytics — SHIP.
Playground (coordinator, real user bugs):
- Multi-image upload: composer attachment (single) → attachments[] (append; multi-select
dialog OR successive uploads/drag-drop accumulate); file input 'multiple'; thumbnail
strip with count + per-image remove; buildRunMessages pushes one image_url part per image.
- 'Run does nothing' (image-only): validateRun now counts an attached image as user
content — an image-only vision prompt is valid and Run proceeds; blocks ONLY a
genuinely-empty message, and the reason renders PROMINENTLY right above the Run button.
tsc clean; vitest 1076/1076 (89 files); next build green.
Customer Machines showed a real but NON-interactive catalog (region chips + every size row were static text) under the "launch your first machine" state, so a data-rich priced catalog you couldn't click read as "not clickable / fake." Make it a real launch surface; finish the last /paas->/cloud data wiring in the cluster path.
- MachineCatalog: every size row is now clickable -> opens the real LaunchDrawer preselected on that size (and region); region chips are selectable filters (filter the size list via pure filterSizes + preset the launch region). Rows reflow to a mobile-friendly 2-column layout (no fixed 4-column table). No fabricated data: with no onLaunch the rows stay informational and an empty catalog still renders nothing.
- LaunchDrawer: accepts initialSize/initialRegion to open preselected.
- CustomerMachines: one openLaunch(preset) opener feeds the header button, empty-state CTA, and the catalog.
- visor: capture the per-size regions[] visor already returns (was dropped in normalizeSize) + pure filterSizes(sizes, query, region), unit-tested.
- platform: repoint provisionCluster from the /paas service-token proxy to native cloud /v1 (/cloud/v1/org/{org}/cluster, user IAM session) - the last /paas call in the cluster path; cluster reads already use /cloud/v1/clusters.
tsc --noEmit clean; visor.test.ts 16/16 (added regions + filterSizes cases). Verified locally headless: tap a size -> drawer opens with the correct $24/mo quote; mobile 0px overflow. Live data lights up once the console redeploys (this branch's /cloud allow-list already admits machines/clusters/gpus) against cloud v1.786.26 which now serves GET /v1/machines.
Backend rolls invisible to customers. Root cause of a real 'Could not load — Upstream
service is unavailable' (Dave/maxpower, Models catalog): cloud is single-replica
Recreate, so a deploy-roll has a brief downtime window; a read landing in it got a
502/503/504 and the console showed a scary manual-Retry card.
Fixed in the ONE shared fetch (client.ts authedFetch → the pure, injectable
resilientFetch) that BOTH the casibase-envelope (request) and plain-REST (restRequest)
paths flow through — covers EVERY client fetch (Models, Overview, Billing, CRM, CMS,
ERP, commerce, analytics, agents, prompts, …), DRY.
- Transient upstream (502/503/504 or a network connection error) on an IDEMPOTENT read
(GET/HEAD) → auto-retry with exponential backoff (300→900→2000ms, up to 3) BEFORE the
honest 'Could not load' card, so a momentary roll self-heals; the card shows ONLY on a
persistent outage (after retries exhaust).
- Genuine 4xx (401/403/404/402) → NOT retried (honest state immediately).
- Mutation (POST/PUT/PATCH/DELETE) → NOT auto-retried (a 5xx'd write may have applied —
re-sending could double-create; the user retries manually).
- Caller-aborted request → honored, never retried.
- The 401 silent-refresh (v8.4.29) is preserved as the second orthogonal resilience,
guarded against a refresh loop.
+13 tests (client-retry.test.ts): the exact Models-catalog 503→200 self-heal, budget
exhaust → honest error, network retry, 4xx/mutation no-retry, abort honored, 401 refresh
no-loop, + classification helpers.
v8.4.33 = the deployed superset (v8.4.32 native-apps set + this). tsc clean; vitest
1061/1061; next build green.
Maximize native app coverage in the console — bind ERP, Content (CMS), Analytics,
and Commerce to their canonical backends per-org / entitlement-gated, one canonical
way, no fabricated data. Contracts verified against source repos + live probes.
Analytics — rebound to the FOUR real cloud clients/analytics routes (overview/
timeseries/top/health; the module had called 5 non-existent endpoints with wrong
shapes). LLM lens is REAL live per-org data (hanzo.cloud_usage, prod ClickHouse);
web/commerce lenses honest-empty via the backend 'available' flag. Dropped the
fabricated Real-Time tab (no backend). Tabs: Overview + LLM (top models).
Content (CMS) — tabbed: NATIVE Collections + Media/DAM read live over Payload REST
through a new /cms user-bearer proxy. Payload's multi-tenant plugin isolates rows by
the IAM owner claim → each org reads ONLY its own (per-org, backend-enforced);
allow-list admits only the two tenant-scoped collections + media bytes, never the
users/tenants registry. Studio tab keeps the entitlement-gated admin embed.
ERP — tabbed + entitlement-gated (Frappe is single-tenant → brand-org/global-admin
only). Overview drives a REAL /v1/platform deploy of the ERPNext app (idempotent
create-project+app+deploy, live status). Accounting/Items/Sales are NATIVE Frappe
REST summary views (real erpnext-v15 DocType fields) over a new /erp proxy (Frappe
token auth, SSRF-clamped, read-only resource lists) — honest 'deploy ERP' until an
instance is live. Desk embeds the real desk once reachable.
Commerce — Products full CRUD (create+delete over /v1/product; validator needs
name+sku+slug); Store settings reads the org's real storefront (/v1/store/current).
Orders/Customers/Inventory/Promotions stay real per-org reads. Via the /commerce
bearer proxy (org from token owner). hanzoai/commerce is the ONE authority — NOT Medusa.
GPUs (drive-by) — KPI reconciled: was distinct-model count (6) vs the catalog table +
Launch drawer configs (9); now shows launchable configs with model count in the sub.
tsc clean; vitest 1050/1050 (+22); next build green (/cms + /erp routes registered).
The zt cloud client bound /v1/mesh/services + /v1/edge/nodes and the heads +
proxy-allow landed, but the two module fetches were left on the /paas proxy.
Switch both to cloudProxyV1Url (the user-bearer /cloud proxy) like the other
repointed modules. Networks already used /v1/networks. Now all 12 infra pages
(compute/DO/platform/zt) read the native cloud /v1 gateway.
Surface the standalone, already-deployed Lux/Zoo web3 apps (Explorer,
Exchange, Bridge, Faucet, Safe, DEX, Wallet) as launch tiles in the Web3
category — not rebuilt in-console, opened at their own domains.
Modeled as a restored `kind: 'external'; href` CatalogEntry member (the
honest sum type for a standalone app that owns no in-console route) — it
slots into the `kind !== 'module'` fail-closed guards the module-only
collapse deliberately preserved, so productSubpages/resolveProductView/
destinationsFor/productModules never manufacture a dead route for it.
`openProduct` becomes the ONE opener: a module navigates to `/<id>`, an
external opens `href` in a new tab. Every card seam (nav, launcher, ⌘K,
category page, level-2 siblings) routes through it, so no external tile
can 404.
Per-entry brand scope (`brands?: BrandId[]` + pure `entryInBrandScope`,
mirroring `nodeNetworksForBrand`) keeps the two suites from cross-leaking
inside the shared Web3 category: Lux tiles show only on lux, Zoo only on
zoo. Every href is a real, verified deployment — no fabricated URLs.
Tests: per-entry brand-scope predicate (no cross-leak); tsc clean; full
suite 1019 passing.
Two GLOBAL-ADMIN operator boards on admin.hanzo.ai (Observe, beside Business +
Finance), two lenses over ONE datastore table split on `kind`: Bots (kind=bot —
@hanzo/bot agents booted, gateway-connected) and Machines (kind=machine — raw VMs
visor opens). Each surfaces per-org/app/project count, active, and spend, grouped
org -> app -> project, sourced from the unified datastore (ClickHouse) via a new
`compute` admin-aggregate head. `/v1/admin/compute?kind=` is server-gated by
getAdminGate (the RED-H1 gate) and rewritten to app/admin/aggregate — no new proxy
or trust boundary.
- lib/api/admin-compute.ts: kind-parameterized, optional-safe client + pure
foldEvents/buildTree over both pre-aggregated {leaves} and raw {events} (9-col
datastore schema: org, app, project, kind, event, machine_id, size, price_cents,
ts). Rollup {count,active,spendCents}; normalizeCompute(raw, kind) filters to kind.
- components/products/ComputeModule.tsx: ONE ComputeBoard({kind}); BotsModule /
MachinesModule are thin wrappers (collapsible org->app->project tree + KPIs,
honest loading/403/404/empty states — honest-empty until the emitter lands).
- registry: `bots` + `vms` (Machines) entries, admin:true; the admin machines module
is aliased to avoid the clash with the per-org customer Machines (visor).
- admin-aggregate.ts + next.config.mjs: `compute` added to the admin read heads.
- Pairs with cloud GET /v1/admin/compute (hanzoai/cloud#62).
tsc clean; npm test 1012/1012; next build green.
Casdoor refresh tokens are ALSO ~3.6KB full-user JWTs, so v8.4.30 sealed cookie
was still 5560 bytes (> browser 4KB cap → a real browser would reject it). Split:
- hz_session (Path=/, sealed {access-exp, projected claims}, ~1KB): resolveUser/BFF.
- hz_rt (Path=/auth, sealed refresh token, chunked hz_rt0/hz_rt1): sent ONLY to
/auth — never to /v1 or the BFF, so no header bloat / gateway-431 risk.
Both sealed (integrity). Live-verified: establish/GET/refresh-rotate all 200.
The v8.4.29 sealed cookie held the raw Casdoor access token (whole user object,
~9.8 KB) which exceeds the browser 4 KB per-cookie limit → a real browser would
reject it (curl does not). sealSession now projects to the small display/authz
claim set + refresh token + exp → a bounded ~1 KB cookie. Live-verified.
The last ComingSoon stub is now a real module. The cloud mlsvc
(hanzoai/cloud clients/ml) fronts the Kubeflow-family CRDs as REST, so
KubeflowModule is the read-only orchestration + control-plane lens over
that live surface (distinct from Fine-tuning's train-my-model wizard):
- Control-plane health strip from a REAL probe (GET /v1/train/health) —
which Kubeflow operators/CRDs (Trainer/trainjobs, Katib/experiments)
are actually served; honest connected/degraded/not-reporting states.
- Pipelines = Katib Experiments (GET /v1/train/experiments).
- Runs = trainer TrainJobs (GET /v1/train/jobs).
Pipelines/runs REUSE TrainApi (one client, no duplication); the new
KubeflowApi adds only the control-plane probe the console lacked (a
tolerant fetch that reads the 503 body restGet would discard). The
/training proxy allowlist gains train/health (additive, read-only).
Registry: kubeflow flips soon -> enabled, routes -> KubeflowModule.
Honest states only, no fabricated data. tsc clean; 84 registry tests pass.
The 94-page screenshot sweep signed in per test (beforeEach), so ~94 logins as
z@hanzo.ai tripped IAM's 'too many login attempts' rate-limit around page 35 —
that's the security feature working, not a page failure. Switch to a shared
serial context: one signIn in beforeAll, every page reuses the cookie. Faster
(~1 login vs 94) and no rate-limit, so the full sweep completes.
- off-list secrets path: accept 401 OR 404 (both block the tunnel; prod hits the
auth gate → 401 before the 404 allow-list check). A 2xx would be the real bug.
- API key extraction: match only a FULL hk- token (16+ chars, no ellipsis) so it
never grabs the masked 'hk-2f18…' account-card display as a credential.
- /v1/messages: pick a model that is ACTUALLY in /v1/models right now instead of
hardcoding claude-sonnet-4-6 (not provisioned on DO → correct 'not available').
All verified against live prod; the surfaces themselves (proxy gating, key mint,
Anthropic-compat inference) already work — these were test-data/expectation drift.
Extends the EXISTING finance living-overview to read the now-multi-vendor
/v1/admin/finance (cloud enriches its cost side from commerce /v1/costs):
- FinanceCost gains {configured, totalCents, vendors[], period}; margin/spend now
reflect the whole-platform COGS (DO compute + LLM providers), not DO MTD alone.
- fromFinance projects cost.vendors onto a 'vendorCogs' distribution (donut) and
makes the headline spend + margin the commerce COGS — decoupled from DO, so a
missing DO_API_TOKEN no longer blanks COGS/margin (DO stays the credit/runway
treasury view + burn-down series only).
- registry: 'COGS (all vendors)' headline + a 'COGS by vendor' donut beside the
burn-down; profitability verdict now gates on commerce COGS, not DO.
- reads ONLY /v1/admin/finance — no console-side /costs proxy (the admin-cogs
duplicate is superseded and dropped).
- tests: vendor donut, zero-line pruning, DO-off-COGS-still-flows, COGS-off honest
empty. 380 unit tests + tsc + next build green.
Issue 1 — Status/Logs/Metrics not wired (o11y):
- /v1/o11y is NOT 503 (stale) — it's a 403 (auth) reverse-proxy to SigNoz, whose
runtime is un-set-up (setupCompleted:false, no data). Status read /paas/apps which
reports ZERO apps; Logs read /paas/logs which 401s (no such endpoint). Neither ever
showed data. The live signal is VictoriaMetrics (up{job=*-health}, ~29 targets).
- New read-only same-origin proxy app/telemetry/[...path] -> VictoriaMetrics query API
(authenticated, GET-only, allow-listed to /api/v1/query|query_range|series|labels|
label/*/values, traversal-hardened, honest 501 when VM_URL unset).
- lib/api/telemetry.ts (pure parse + service-health helpers, unit-tested).
- StatusModule: real up{} service-health board (down-first, healthy/down counts).
- MetricsModule: real VM infra dashboard (KPIs, healthy/targets-over-time, health
donut, down-now) — replaces the unwired 'metrics' NativeOverview; distinct from
AI Metrics. LogsModule: honest 'no log store deployed' state (no fabricated grid).
Issue 2 — launch-machine form: FieldSelect used @hanzo/gui <Select native>, which in
gui 7.3.0 emits bare <option> with NO <select> wrapper — every dropdown app-wide (27
usages) rendered as a non-interactive flat list. In the launch drawer this buried the
Quote + Launch button. FieldSelect now renders a real native <select> (theme-var
styled, native mobile picker), fixing the region picker and all other dropdowns.
Issue 3 — mobile: the FieldSelect fix repairs every form's pickers; DataTable now
scrolls horizontally on overflow instead of clipping wide tables (cut-off columns).
Verified live (VictoriaMetrics + real z@hanzo.ai session): Status 29 services/22
healthy/7 down, Metrics dashboard, Logs honest state, launch drawer native region
select with Launch visible, mobile table scroll. tsc clean; 964 vitest pass.
Also: dev-only next.config rewrite (DEV_CLOUD_ORIGIN, inert in prod) to run the
console locally against a real backend; removed orphaned observability/metrics.ts.
The Templates gallery cards were text-only and dead-ended after fork
("Draft — deploy it to go live" with no action). Two fixes:
1. Preview banner: render the gallery screenshot (t.preview) with a branded
gradient fallback (stable per category + framework glyph) when it's absent
or 404s — cards are visual immediately and auto-upgrade to the real shot
once gallery.hanzo.ai serves it. No broken images, no fabricated screenshots.
2. Deploy flow: fork→draft now shows a clear "Deploy" button that ships the
project live via projectsvc git deploy (POST /v1/projects/:slug/deploy
{source:git}); building → "Check status" → "Open site" (liveUrl). Each
phase shows exactly one next step, so "how to deploy" is never ambiguous.
TemplatesApi gains deploy()/status()/isLive()/normalizeDeployResult over the
existing same-origin /v1 surface. 18 vitest tests, next build green (14/14).
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Add an 'Open in builder' primary CTA to each starter card that deep-links to
the hanzo.app builder pre-seeded to customize this template by prompt:
<app>/dev?template=<source>&prompt=<seed>&action=edit. A small inline input
takes an optional free-text customization; the seed prompt carries the template
context (title/framework/description) so the builder auto-starts the first
generation.
- buildBuilderUrl(template, userText, appBase) + customizePrompt(): pure,
injection-safe (single URL-encoded query params), unit-tested (+7 tests).
- config.appUrl (NEXT_PUBLIC_APP_URL, default https://hanzo.app).
- 'Fork / deploy' kept as the secondary action + honest 404 gallery fallback.
tsc --noEmit clean; vitest 881/881.
RED reviewed v8.4.22's embeds (0 critical; SSRF clamp, iframe SOP, no-credential-
injection, honest normalizers all REFUTED). Fixes the console residuals RED flagged:
- Server-side entitlement gate: /embed-status resolves the caller's org (token owner)
and returns `entitled` per app. cms/erp/help are all brand-owned single instances,
so a non-owning (customer) org gets entitled:false + NO embed URL + no probe -> the
module shows the provision panel. Only a brand-org member / global admin embeds.
This is now the AUTHORITATIVE gate (the client check was cosmetic). Client normalizer
fails closed (entitled strict-true; stale server -> provision panel, never a frame).
- Help is brand-owned too (was embed-for-all) -> a customer never frames the shared
Frappe Helpdesk (removes unverified cross-org ticket-visibility risk).
- Dropped the false 'org==tenant enforced server-side' claims in EmbeddedApp/
embed-hosts/module docstrings (the console gates WHO it frames; the shared app still
owes its own per-org isolation -> separate CMS-side fix).
- Trimmed the iframe sandbox (dropped allow-top-navigation-by-user-activation,
allow-popups-to-escape-sandbox, clipboard-read).
- /waitlist: bind recorded email to the session account; stop forwarding forgeable XFF.
tsc clean; vitest 952/952 (+13 entitlement/brand-org/fail-closed); next build green.
CMS was a window.open link-out; ERP/Help were 'soon' placeholders. Port all three
into the console as EMBEDDED (SSO iframe) or HONEST-provision surfaces, binding to
the canonical Payload/Frappe backends (not reimplemented). CRM stays the native
/v1/crm reference; these are the embed half of the Business-OS.
- EmbeddedApp: the one way to frame a canonical app IN the console shell (full-height
iframe, scoped sandbox, real loading + honest 'Open full screen' fallback — never a
fabricated load verdict). ProvisionPanel: DRY honest pre-provision surface over the
real /waitlist intake.
- embed-hosts.ts (PURE): white-label cms|erp|help.<brand> host derivation.
- /embed-status BFF + pure embed-probe.ts: session-gated reachability probe, NO
god-mode, SSRF-clamped to known brand domains, AbortSignal-bounded.
- CmsModule: embed the Studio for a brand-org member/global admin ONLY (customer org
gets an honest provision panel — no cross-tenant framing of the shared instance).
- ErpModule: erp.<brand> is 502 -> honest 'Deploy ERP' panel; SAME gate embeds the
real desk once live. HelpModule: embeds the live shared brand support desk (Frappe
scopes tickets per-user via SSO).
- registry: erp + helpdesk soon -> enabled native modules.
Verified against live cluster + repos (single shared HANZO_ORG=hanzo instances; no
per-customer-org isolation yet) so nothing claims tenancy it doesn't have.
typecheck 0 errors; vitest 914/914 (+21); next build green (/embed-status registered).
Investigated the "brand host (cloud.lux.network) hangs during render, while
console.hanzo.ai is fast" report. It does NOT reproduce in the app and cannot by
design: the page-render path (app/layout.tsx + (dashboard)/layout.tsx, the only
server components) does ZERO per-brand network fetch — no next/headers, cookies(),
generateMetadata, or server-only render fetch. Brand resolves from window.location
in the browser; SSR uses the build-time NEXT_PUBLIC_DEFAULT_HOST, so the server
HTML is byte-identical for every brand host (verified: cloud.lux.network and
console.hanzo.ai return the SAME md5, <title>Hanzo Cloud Console</title>, HTTP 200
in ~4-18ms for lux/zoo/pars/hanzo). The prod origin difference is an ingress/
routing artifact, not app SSR (out of scope; app code only).
The real in-code "no timeout → the route wedges" hazard (the described failure
class) IS fixed: every request-time server fetch — the /v1/* BFF proxies plus IAM/
cloud identity resolution — had NO upstream timeout, so a reachable-but-silent
backend blocked the route until the client gave up.
Fix, DRY: one src/lib/server/fetch-timeout.ts (fetchWithTimeout) — a bounded
AbortSignal COMPOSED with any caller signal (init.signal / req.signal), so a
request aborts on EITHER a client disconnect OR the timeout. Default 10_000ms, env
HANZO_UPSTREAM_TIMEOUT_MS. On timeout it rejects like an aborted fetch, so every
existing catch keeps its honest fallback (resolveUser → null → 401; proxies → 502).
Threaded through identity.ts (all 5 IAM/cloud calls), bearer-proxy.ts (the shared
proxy engine → cloud/ai/vm/tasksd/commerce/superbase), iam-proxy.ts, and the
custom proxies (/paas, /training, /admin/kms, /billing, /billing/topup/wallet,
/waitlist). The /nodes per-brand luxd RPC probe was already bounded — left as-is.
Verification: npm run typecheck 0 errors; npm test 823/823 (69 files, +6 new
fetch-timeout tests); next build green (all routes); Host-header curl test returns
200 fast for BOTH cloud.lux.network and console.hanzo.ai.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A tenant (non-global) admin like `hanzo/z` can only ever act in their OWN org —
the server pins them there and every cross-tenant call 403s. But `OrgGate`
restored a persisted "last org" (`hz_last_org` / `hanzo.console.org`) on sign-in
WITHOUT checking the user can access it. So a leftover `adnexus` scope (from a
prior global-admin switch) made every org-scoped call — e.g.
`/org/iam/get-organization-projects?organization=adnexus` — 403 for z.
Fix: in the OrgGate seed effect, a non-global admin is ALWAYS hard-pinned to
`owner`. If a stale cross-tenant scope is active it's reset and the page reloads
once (guarded on `currentOrg() !== owner`, so it can't loop) — self-healing
existing stale state and preventing recurrence. Global admins keep the
switch-and-restore behavior. Complements the v8.4.18/19 gates on the admin
aggregates; this closes the org-SCOPED 403s.
Co-authored-by: dev <dev@hanzo.ai>
Follows v8.4.18 (#44), which gated only the overview aggregate + /paas probe.
This release also gates the OrgSwitcher/CommandPalette org list and BrandLogo's
org-logo lookup, so a per-org (non-global) admin's dashboard load fires ZERO
cross-tenant /admin/iam calls — no more 403 noise in the browser console.
v8.4.18 (#44) gated the platform-overview loader (`/v1/admin/overview` + the
`/paas/apps` health probe) on `isGlobalAdmin`, but the dashboard chrome still
fired two more CROSS-TENANT admin calls for every user on every load:
- `/admin/iam/get-organizations?owner=admin` — the OrgSwitcher + CommandPalette
org list, server-gated to global (admin-org) admins.
- `/admin/iam/get-organization?id=admin/<tenant>` — BrandLogo's org-logo lookup.
For a per-ORG admin who is NOT a global admin (e.g. `hanzo/z`) both 403, so a
tenant still saw a wall of red 403s in the browser console on every dashboard
load even after v8.4.18.
Gate them with the SAME `useIsGlobalAdmin` signal the overview loader + nav use:
- OrgSwitcher / CommandPalette: skip `get-organizations` for a non-global-admin
(they still see their current org + "Create organization"; just no cross-tenant
switch list).
- BrandLogo: a tenant reads its OWN org logo via the org-scoped `/org/iam` proxy
(`TeamApi.organization`, which authorizes any member and pins to the caller's
own org) instead of the admin-gated `/admin/iam` proxy — the logo still works,
minus the 403.
Global admins are unchanged. No permission or server change; server gates still
enforce. typecheck + 887 tests + next build all green.
The platform overview home (`livingOverviewModule('overview')`) is the default
landing for every signed-in user, but its loader fired the CROSS-TENANT
`/v1/admin/overview` aggregate (and the `/paas/apps` health probe) for everyone.
Those are server-gated to global (admin-org) admins, so a tenant user — even an
org's OWN admin, e.g. `hanzo/z` — got a wall of repeated `403 (Forbidden)` in the
browser console on every dashboard load.
It "worked" only because the loader caught the 403 and fell back to the org-scoped
usage ledger; the board rendered, but spammed the console with doomed requests.
Fix: thread `isGlobalAdmin` (already resolved by `useIsGlobalAdmin`, the same
signal the nav/launcher use to hide admin surfaces) through the ONE loader call
site into `OverviewContext`, and in the platform-overview loader skip the admin
aggregate + apps probe for non-global-admins — going straight to the org-scoped
usage ledger (the exact source the catch-fallback already used). Global admins are
unchanged. `withHealth` gains an optional `probeApps` (default true) so the four
other, already admin-gated overviews are untouched.
Net: a tenant admin's overview renders identically, minus the 403 console noise;
no permission or server change.
Co-authored-by: dev <dev@hanzo.ai>
* feat(console): consolidate CRM + Accessibility Business-OS modules over native /v1
CRM — the first Business-OS brick — as ONE canonical module over the native-Go
cloud /v1/crm surface (cloud clients/crm on Base/SQLite: companies, contacts,
opportunities; a port of Twenty's core model), per-org via the user-bearer /cloud
proxy. Consolidates the two competing CRM PRs (#38 feat/console2-crm and #39
crm-work): keeps #39's richer routed-tab views + defensive normalizers + tests,
re-paths its API from the explicit /cloud/v1 form to the canonical same-origin
originV1Url('crm') (matching Agents/Evals/Prompts — the one way), and grafts #38's
next.config `crm` head rewrite + per-row delete. crm.ts is the single typed mirror
of the /v1/crm contract (one method per route); every row is org-scoped SERVER-SIDE
from the token owner claim, with honest loading/empty/error states in @hanzo/gui.
Accessibility — a Wix-style WCAG scanner for the site being built. Runs Deque's
axe-core against the current page 100% client-side (engine lazy-loaded into its own
chunk, never the main bundle); pure sort/summarize/WCAG-label logic in
~/lib/a11y/scan is unit-tested without a browser or engine.
- next.config.mjs / proxy-allow.ts: allow-list the `crm` head on both the rewrite
and the bearer proxy (same least-privilege path as the 5 existing surfaces).
- registry: CRM + Accessibility in the Apps catalog.
- version 8.4.16 -> 8.4.17; axe-core 4.12.1 (lazy import, own chunk).
typecheck clean; 887 unit tests pass (13 new: 9 crm + 4 a11y); next build green.
* feat(console): fold Content Studio + ERP/Help Center into the Business-OS suite (v8.4.17)
Consolidates the three overlapping Business-OS PRs into ONE canonical superset.
Onto the #39 base (canonical CRM over originV1Url → /v1/crm direct + per-row
delete, and the client-side axe-core Accessibility scanner), fold in #42's:
- CmsModule (Content Studio: honest in-console home for the live Payload CMS)
- registry entries cms (Content), erp + helpdesk (honest soon → ComingSoon)
Reconciliation: kept #39's originV1Url CRM (hits /v1/crm directly, the majority
agents/prompts/evals pattern) over #42's /cloud-prefixed cloudProxyV1Url; kept
#39's CrmModule (superset — adds delete); dropped #42's stray gcp:'Content';
regenerated package-lock.json for axe-core 4.12.1 (was package.json-only).
Supersedes #38, #39, #42.
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Deep-links /billing/reports (now unshadowed) and asserts the god-view gate matches
the account grant (z is a hanzo org-admin → honest 403 + managed fallback; an
admin-org member → 200 KPI board). No app change.
The per-tenant commerce DATA proxy (app/billing/[...path]/route.ts) claimed the
whole /billing/* URL space; a Next route handler wins over the catch-all page for a
matching segment, so every billing tab except Overview (/billing/reports, budgets,
invoices, subscriptions, payment-methods, credits) resolved to the proxy and
returned commerce {"error":"not found"} / raw JSON instead of the UI. This
blocked the v8.4.15 Reports cost-dimension (product/agent) surface in production.
Namespace the data proxy under /billing/v1/* (the 'always /v1/' convention) so it
can never collide with a UI tab slug; tab URLs fall through to app/(dashboard)/[...slug].
- move app/billing/[...path] -> app/billing/v1/[...path]; topup/wallet likewise
(forward target commerce/v1/billing/<path> unchanged — v1 is now a static segment)
- billingUrl() (billing.ts + aimetrics.ts) and wallet.ts appUrl() prepend v1/
- tests: aimetrics.test.ts asserts /billing/v1/usage; billing-isolation e2e fetches /billing/v1/<p>
tsc clean; 874/874 tests; next build green (routes /billing/v1/[...path],
/billing/v1/topup/wallet, /[...slug] catch-all serves the tabs).
Add a global-admin-only Finance board to admin.hanzo.ai over the ONE
LivingOverview system — DigitalOcean credit burn-down (our primary ~$40k
venue), month-to-date spend, MRR, total revenue, gross margin %, runway,
and a profitability health verdict. TRUE by construction: every tile reads
the real `/v1/admin/finance` aggregate; no fallback fabricates numbers.
- lib/api/finance.ts: FinanceApi.finance() + normalizeFinance — optional-safe
map of `/v1/admin/finance` onto Finance. Reads via originGet (same-origin
`<origin>/v1/admin/finance`), so the request rides the global-admin-gated
aggregate proxy. runwayDays preserves NULL (never coerced to 0 = fake alarm).
- overview/living/adapters.ts: fromFinance (payload → OverviewData, honest-empty
on unconfigured DO / commerce) + pure financeHealth verdict (green profitable /
yellow thin-margin <20% / red burning-faster / '' unknown when DO off).
- overview/living/registry.ts: `finance` living config — 6 KPI tiles + credit
burn-down bar series + health/alerts. No try/catch fallback (finance is
true-by-construction; a denied/not-routed backend renders the honest error).
- products/registry.tsx: `finance` catalog entry, `admin: true` (Observe) —
hidden from every customer's nav/launcher/palette; the catch-all renders a
managed notice for a non-admin. Reuses livingOverviewModule; no new UI.
- SECURITY: financial data is Hanzo-internal. Routed through the `/admin/
aggregate` bearer proxy (getAdminGate: verified @hanzo.ai + IAM global-admin,
fail-closed 403) — NOT the generic /cloud product proxy. `finance` added to
ADMIN_AGGREGATE_HEADS (admin-aggregate.ts) + ADMIN_V1_HEADS (next.config.mjs);
the allow-list still REFUSES admin/iam + admin/kms. A non-admin can never see
or reach the board or the data.
tsc --noEmit: 0 errors. vitest: 874/874 (incl. +8 finance: normalizeFinance,
fromFinance, financeHealth, allowAdminSurface finance head). next build: ✓
compiled, 14/14 pages.
H1 (HIGH): the admin business god-view had NO console-side server gate — /v1/admin/*
was not rewritten and rested solely on an unverified cloud-side gate. Add
app/admin/aggregate/[...path] behind getAdminGate (global-admin only, fail-closed
403) → forwardWithUserBearer; rewrite /v1/admin/{overview,usage,orgs,audit,products}
to it (iam/kms untouched). New originGet pins AdminApi to the console origin so a
split-origin NEXT_PUBLIC_CLOUD_URL can't bypass the gate. Least-privilege surface is
the pure, tested lib/server/admin-aggregate.ts (refuses admin/iam, admin/kms).
L1 (LOW, proven): agentUsageFor id-OR-name Set union conflated two agents within an
org. Prefer exact id, fall back to name only when id matched nothing. +collision tests.
L2 (LOW, proven): unbounded cost table under high agent/product cardinality. New pure
capRows (COST_ROW_CAP=100) bounds the DOM to top-by-spend with an honest Show-all
affordance (never hides real data). +tests.
RED-refuted vectors verified safe (fallback scope, metadata forgery, DoS, honest
state, client gating). typecheck clean; 853/853 tests; next build green (new route
registered).
Usage/billing visibility:
- aimetrics UsageRecord extracts metadata.{product,agent} (canonical contract);
perAgent + agentUsageFor rollups over the SAME charged commerce ledger.
- Cost Reports add product + agent breakdowns (presentDimensions gates each on
real data — honest until spend is tagged); BillingReports renders them.
- Agents detail pane shows per-agent cost from the ledger (agentUsageFor), not a
hardcoded/registry metric; honest '—' until attributed.
admin.hanzo.ai business dashboard (global-admin only):
- New admin-business living overview (MRR/revenue/usage-cost/orgs/customers,
revenue-by-product + plan-mix + top-agents-by-cost donuts, alerts, activity,
fleet health) over /v1/admin/overview allOrgs, honest usage-ledger+operator
fallback; reuses the ONE LivingOverview system.
- admin-overview gains an optional named-distributions map (revenue/plans/
topAgents), present only when the backend sends it; fromAdminOverview projects
each into distribution[key].
- Registry catalog entry 'business' (Observe, admin:true) — gated by getAdminGate
+ useIsGlobalAdmin; the aggregate is server-gated.
Mobile-responsive by construction (gui v5 shorthands + flexWrap rows, no fixed
grids). typecheck clean; 841/841 tests; next build green. /v1 only.
The gallery "Fork / deploy" button now creates a REAL project in-console via
POST /v1/projects/fork (cloud projectsvc) instead of only opening the gallery
source URL — the ONE way to start a project from a template.
- lib/api/templates.ts: TemplatesApi.fork(slug, {name?}) POSTs to the
same-origin /v1/projects/fork (originV1Url, no prefix) and normalizes the
returned projectsvc Project (normalizeForkedProject, pure/tested).
- TemplatesModule: per-card idle -> forking -> created state; on success shows
the new project (Open site when a liveUrl exists, else honest draft). On a 404
(older backend without the fork route) it falls back to opening the gallery
source, so the button is never dead.
- next.config.mjs + proxy-allow.ts: add the `projects` head so /v1/projects*
(incl. /fork) routes through the hardened /cloud bearer proxy, org-scoped from
the Bearer owner.
Tests: normalizeForkedProject + TemplatesApi.fork (exact same-origin URL, body
with/without name override, 404 ApiError for the fallback); proxy-allow admits
the projects fork subtree. tsc clean; vitest 834/834.
console2's canonical, decoupled agent builder becomes the true superset: its
host-injected loader seam (unchanged) + hanzo.chat (@hanzo/ai)'s advanced
generation config. One component over the ONE /v1/agents backend — hanzo.chat
and console v8 render the identical builder.
Contract (types.ts):
- AgentConfig: temperature/topP/topK/stream/thinking/useTools/webSearch +
reasoningEffort, all with defaults; folded onto AgentSpec as OPTIONAL config +
knowledge, so a simple agent is unchanged.
- ReasoningEffort = the full Hanzo/Claude ladder low·medium·high·xhigh·max·
ultracode (ultracode = xhigh+workflows, top tier, use sparingly) — supersedes
@hanzo/ai's old 3-level enum.
- AgentCreateBody: the pruned wire value the builder emits; createAgent now
takes the body (no more 'as AgentSpec' cast at the call site).
Pure logic (logic.ts): defaultConfig/clampConfig/pruneConfig +
normalizeList(→tools,knowledge, DRY). toCreateBody prunes every default knob, so
opening Advanced never changes what a simple agent posts. clampNum fixed: NaN→min
but ±∞ clamp to the nearest bound (slider-to-top lands on max, not min).
UI (AgentBuilder.tsx): hidden-by-default Advanced section (sliders/switches/select
over the existing Field primitives). loaders.ts + NewAgentBody pass config+knowledge
through to POST /v1/agents.
Verified: all pure transforms proven via a standalone node harness (22/22 incl.
backward-compat, clamp edge cases, prune, ultracode). tsc/vitest run in CI.
CI workflow now builds+pushes ghcr.io/hanzoai/console:v<version> directly
(no more console2→console retag hack). registry product source-links and the
build/deploy doc updated to the renamed repo. Image name == repo name, one way.
One product, two build lineages: console ships app-semver 8.4.x, cloud
ships its own Go-module v1.786.x (never above v1 — Go module semantics +
the standing rule). Unify only the STORY under a "Hanzo Cloud <MAJOR.MINOR>"
umbrella, single-sourced from the console app version (no second place holds
it): next.config injects NEXT_PUBLIC_APP_VERSION from package.json;
config derives branding.release ("8.4") + branding.productLine
("Hanzo Cloud 8.4"), shown on the sign-in screen. Convention documented in
LLM.md. Nothing in Go changes. typecheck clean, vitest 793/793.
Cut a fresh semver from a verified-clean main HEAD so CI publishes
ghcr.io/hanzoai/console2:v8.4.11. Tree already type-checks + builds
clean (concurrent lanes resolved the earlier RecordsModule maxW /
metrics.test null-type breakages); this bump is the publish trigger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user asked to call it just 'S3'. Rename the user-visible product
name everywhere it surfaces — the catalog entry (registry.tsx label +
description), the StorageModule page header title, and the Zero Trust
data-plane posture row — plus the two resource-console doc comments that
list the kinds. The provisioning kind id stays 's3' and the file-manager
backend (/v1/s3) is unchanged; this is a label rename only.
typecheck clean, vitest 778/778 green.
Makes Base usable BY CLICKING, not just via the API — the gap the live Playwright
check found (the base admin UI is read-only). A 'Records' product (Data category):
browse a collection, open a record, edit it, create new ones — all rendered from
the collection's OWN field schema through @hanzo/data (DataTable for the list,
RecordDetail/RecordForm for detail; every field type is now editable per
hanzoai/ui#232). Data flows through console2's per-user /superbase proxy (IAM
bearer minted server-side; the proxy allow-list gains the collections + records
paths). Routes: /records (index) · /records/:collection · /records/:collection/:id
(:id=new → create).
base-data/{api,fields} map Base schema → @hanzo/data FieldDefinition[] and do the
list/get/create/update/delete; CollectionTable + RecordDetailView are the views.
Registered next to Base (the backend) — Base is the store, Records is the app on
top. 19 base-data tests pass; RecordsModule + registry typecheck clean (the 4
remaining tsc errors are pre-existing: @hanzo/dash local-only + metrics.test).
Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI token can push the existing console2 ghcr package but lacks
write_package to CREATE a new ghcr.io/hanzoai/console package, so the atomic
buildx --push of both tags failed and v8.4.9 published NEITHER image. Back to
single console2 tag so releases build again. The console2 → console image
rename needs the ghcr package created + repo-linked first (an
org/settings step), then the tag can be added.
Per-org Analytics product over cloud clients/analytics: Overview (hanzo.events +
events_daily), Real-Time (live sessions/feed), LLM (cloud_usage + observations).
Same-origin /v1/analytics/* via the /cloud bearer proxy; analytics head added to
proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS. Registered under Observe.
Every metric a real warehouse query; honest-empty otherwise.
Dual-tag release so the console image exists at this version before the operator
CR + gateway cut over from console2 → console in one coordinated deploy.
'console2' was only ever a working name — the package is already @hanzo/console
and it's v8. Start the rename by publishing ghcr.io/hanzoai/console:<ver>
ALONGSIDE console2:<ver>. Purely additive — nothing consumes the console image
yet, so no rollout can break. The cutover (operator CR + gateway k8s Service
console2→console, in ONE deploy) and dropping the console2 tag follow once the
new image has published on a release.
Rebased onto main v8.4.7 (Prompts/Agents/Evals live via next.config /v1 rewrites,
dynamic model+prompt agent builder, ComboBox). Was fe3ee26 (v8.4.5).
Cross-tenant eval fix ships in cloud v1.786.10.
Products inherit a per-category color family by default (one color per category:
AI/Compute/Data/Security/…), user override still wins, no-category callers keep
their legacy curated pick. colorOf/keyOf thread the entry's category so the sidebar
icons, collapsed rail, and L2 header recolor per category in one place. Category
landing (/category/<slug>, CategoryOverview — already built) tiles + header glyph +
the L1/L2 category labels are tinted the category color. colors.ts pure + tested.
tsc clean, vitest 711/711, next build green.
The sidebar product filter is hoisted out of the level-1 slide panel into a
persistent header above the two-level slide, so a user deep in a product's
sub-pages can filter + jump straight to another product without going Back.
showLevel2 yields to the product list while filtering; selecting a result
clears the filter and slides to that product's level-2 sub-nav. One input,
one predicate (entryMatches), one list. Mobile drawer inherits it.
tsc clean, vitest 706/706, next build green.
PRIORITY 1 — S3 file manager. Upgrades the 'Object Storage' catalog entry from
the generic provisioning resource card to a REAL S3 file manager over the
org-scoped /v1/s3 control plane in the unified cloud binary (hanzoai/cloud
clients/s3). One console, one backend — no external s3.hanzo.ai UI.
- src/lib/api/storage.ts (StorageApi): buckets list/create/delete, object list
(folder-style via prefix), delete, and presigned upload/download. Metadata ops
go through the same-origin /cloud user-bearer proxy (cloudProxyV1Url — s3 is
already allow-listed in proxy-allow.ts). Upload/download use PRESIGNED URLs:
the backend mints a time-boxed URL scoped to the exact bucket+key and the
browser transfers bytes DIRECTLY to S3 — bypassing the proxy (which buffers the
body as text + forces JSON content-type, corrupting binary) and never exposing
the admin credential. Defensive normalizers (billing.ts style); org is
server-authoritative (never a browser claim).
- src/components/products/StorageModule.tsx: bucket list (create/delete) plus an
object browser with breadcrumb folder navigation, upload (file picker to
presigned direct-to-S3), download, delete. Honest loading/empty(first-run)/
BackendState (503/404/403) states — never a fabricated bucket/object. hanzo/gui
v5 shorthands; mirrors FunctionsModule.
- registry.tsx: s3 entry now routes to StorageModule (repo hanzoai/s3).
PRIORITY 2 — KMS repoint. src/lib/server/identity.ts KMS_URL default
http://kms.hanzo.svc (legacy Infisical fork) to http://cloud.hanzo.svc:8000
(embedded cloud KMS serving /v1/kms/orgs/{org}/secrets natively, HIP-0106). The
/admin/kms proxy forwards to kmsBaseUrl + /v1/kms/orgs/{org}/secrets — matches.
CAVEAT (documented, not a blocker): the embedded KMS is health-only (secret ops
503) until CLOUD_KMS_MASTER_KEY_REF is provisioned + secrets migrated; the KMS
module shows its honest 'not initialized' state until then.
Tests: vitest storage.test.ts 15/15 (normalizers, folder detection, presign,
traversal-safe key encoding, /cloud-proxy URL construction, direct-to-S3 upload).
tsc --noEmit clean. next build green (14/14 pages). Version 8.4.1 to 8.4.2.
A new Commerce category surfaces the hanzoai/commerce merchant store natively
inside the console (no admin.commerce.hanzo.ai subdomain). Six pages —
Products, Orders, Customers, Inventory, Promotions, Store settings — each a
native module over the real commerce backend, scoped to the signed-in org.
- BFF: app/commerce/[...path]/route.ts forwards to commerce.hanzo.svc:8001 via
forwardWithUserBearer (mints a short-lived user IAM token; commerce EdgeAuth
resolves the org from the owner claim). Least-privilege allow-list
(allowCommerceSurface / COMMERCE_HEADS): only the merchant REST heads
(product/order/user/variant/discount/collection/store/…) — /billing, /checkout,
/_/commerce/tenants are NOT reachable (money stays on the /billing proxy).
- Client: src/lib/api/commerce.ts (CommerceApi) — defensive normalizers over the
real {count,models,facets} envelope; empty store → honest empty, never faked.
- UI: one CommerceResource list (fetch → loading/empty/error via BackendStateCard)
+ six thin pages; Store settings notes Square/Billing. @hanzo/gui v5 shorthands.
- Category: 'Commerce' added to brand-scope (hanzo shows it; web3 brands don't).
- No Stripe, no new DB, no billing-engine change. Payments remain Square via
hanzoai/commerce /v1/billing.
Verification: tsc --noEmit clean; vitest 52 green (commerce/logic/proxy-allow/
brand-scope); next build ✓ (/commerce/[...path] compiled). The one failing test
(billing/logic.ts groupSpend ordering) is pre-existing on origin/main, unrelated.
Live browser pass as Dave (maxpower) found pages that were connected-but-read-as-broken:
- Machines: /vm/v1/machines 403s for a signed-in customer (visor authorizes the
public catalog but denies the per-org list) → the page said 'Sign in to view your
machines' next to the real region/size catalog. interpretVisorError now maps 403→
connected-managed (only 401 = sign-in); CustomerMachines shows 'Launch your first
machine' + the live catalog, never a sign-in wall.
- platform/state forbidden: reframed to 'Connected · managed by Hanzo' (green check, no
warning triangle, no Retry) so Containers/Edge/Applications read connected, not error.
- Applications: repointed from casibase IAM OAuth apps (get-applications) to the DEPLOYED
app services (/v1/apps) — connected/managed/empty states + deploy-via-Functions/Agents.
- Agents: 'Connected · no agents yet' banner on the live 200-empty state.
- Proxy defaults hardened (vm/cloud/tasksd): '|| default' (not '??') so an env
reconciled to an EMPTY string still resolves the in-cluster service.
tsc clean; vitest green (visor test updated for 403→connected).
Agents: rebuilt AgentsModule into a rich dashboard over /cloud/v1/agents (was
/paas): 5 stat cards, invocations area chart, health donut, agents table with
status tabs + pagination + version badges, recent-activity feed, top-agents bar
list, 30d resource-usage panel. Every number real/derived; polished
create-first empty state + real New-Agent flow. New lib/api/agents.ts (+22 tests)
+ agents/{parts,forms}.tsx.
Machines: customer branch shows the real visor region/size catalog + pricing
(MachineCatalog) under the launch state — never blank.
GPUs: role-routed like Machines — customer sees the real visor GPU catalog +
their GPU machines (CustomerGpus); admin keeps the /paas fleet. Overview route
is role-aware (GpusOverview). +4 visor catalog normalizer tests.
Containers: surface the apps 403 as a graceful 'Managed control plane' card
(was a masked bare-empty table). Edge: honest coming-soon/managed state.
platform/state.tsx: split 401/403 (forbidden → 'Managed control plane') from
501 (not-configured → admin token hint) so customers never see the false
PAAS_SERVICE_TOKEN message across every /paas module.
visor.ts: add regions()/sizes()/gpus() catalog + normalizers.
tsc clean; vitest 639+ green; next build 14/14.
Part A — Billing Center (one `billing` catalog entry, tabbed):
- Consolidate the scattered Cost/Subscriptions/Payment-methods entries into ONE
`BillingModule` (registry ''+:tab) under Observe. Delete superseded CostModule.
- Overview: balance/credits + month-to-date spend + clearly-labelled linear
projection + daily-spend trend (real /v1/billing/usage ledger, pure logic.ts).
- Reports: cost breakdown by real ledger dimension (model/provider — no invented
project/SKU), filterable table + BarChart + spend-share Donut over a range.
- Budgets: REAL create+list over commerce spend-alerts (GET/POST /v1/billing/
spend-alerts). Edit/delete withheld pending commerce per-alert ownership check.
- Invoices: GET /v1/billing/invoices + download; honest empty.
- Subscriptions/Payment methods/Credits: reuse existing modules verbatim as tabs.
- Add `scopedBillingBody` (billing-scope) so a write body's subject is pinned
server-side — create-budget works without the browser knowing its subject and a
forged body subject can't widen scope. Wired into the /billing proxy write path.
Part B — billing-only shell (billing.<brand> = same image, filtered):
- config.billingOnly (host billing.<brand> OR NEXT_PUBLIC_BILLING_ONLY=1).
- visibleCatalog / DashboardShell nav filter to the Billing Center sub-pages,
full chrome kept; default route redirects / -> /billing.
- cmd+K + AppLauncher source from visibleCatalog so scoping is consistent.
Honest states everywhere; no fabricated data. New unit tests: billing/logic,
scopedBillingBody, config billing-only host. e2e/pages.spec updated for the
consolidation. Docs in LLM.md.
One authoritative build ending the deploy-war. Two genuinely-unmerged Network
modules land (nodes: per-node luxd validators/peers; dns: per-org managed DNS);
all other session branches were already on main (git cherry-verified) and are
pruned, not re-merged. The api.hanzo.ai-gateway default change was rejected (CR
documents in-cluster CF-403 on public hosts; safe default is cloud.hanzo.svc).
Fixed pre-existing observability/metrics.test.ts null-override types so
tsc --noEmit is fully green.
tsc --noEmit clean; next build is the authoritative Node-24 gate (on-cluster
Kaniko, no GitHub builders).
The DNS module cherry-pick (bee05edc) added a second id:'dns' catalog entry while
main already carried a DNS overview stub in the Network cluster. Upgrade that
well-placed stub to render the real DnsModule (zones/records over /v1/dns) and
remove the duplicate — one id, one entry, one way.
Add a real `nodes` catalog entry (Network category) surfacing individual luxd
node infrastructure — validators (P-chain platform.getCurrentValidators) + peers
(info.peers) — across networks, wired to LIVE luxd RPC. Complements the Bootnode
`networks` module (network-level counts) with per-NODE inventory. REAL data only;
honest "not reporting" per unreachable network, honest empty otherwise.
- app/nodes/[...path]/route.ts: same-origin proxy mirroring the /bootnode security
pattern — session-gated, brand/org-aware (brandFromHost), least-privilege. Only
path is v1/inventory; only the four read methods getCurrentValidators/peers/
getNodeVersion/getHeight are called server-side. Per-network RPC hosts in a small
env-overridable map; unreachable host -> honest not-reporting, never fake rows.
- lib/api/nodes.ts (extended; cluster-capacity logic untouched): pure
normalizeValidators/normalizePeers/combineInventory -> uniform NodeRow, dedupe by
nodeID (validator wins, version enriched from peer), parseUptimePct/parseHeight/
fmtWeight; NodesApi browser client over the proxy.
- lib/products/brand-scope.ts: nodeNetworksForBrand DATA scope — hanzo=all networks
(super-admin/infra view), lux/zoo/pars scoped to their own chain. Nodes lives in
Network so category scope admits it on every brand.
- components/products/NodesModule.tsx: per-network summary cards + network filter +
DataTable (Network/Role/Node ID/Version/Status/Uptime/Height); BackendStateCard/
EmptyState honest states.
- Confirmed live (2026-07-01): lux mainnet/testnet/devnet, pars-mainnet. zoo has no
confirmed public host yet -> honest not-reporting.
Tests: nodes normalizers (real captured wire shapes) + brand->network scoping.
typecheck clean, vitest 404/404 (37 files), next build green. package 8.2.1->8.2.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Network-category console module: lists org-scoped DNS zones and their records
on the unified /v1/dns surface (api.hanzo.ai gateway → hanzodns → CoreDNS +
Cloudflare sync). Honest BackendStateCard states (401/404/503) until the route
is bound — never fabricates a zone/record. Mirrors the Networks module; uses
@hanzo/data DataTable + the X-Org-Id the cloud client stamps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidates 5 reviewed branches (PRs #24#25#28#29#35):
- Model catalog: real Zen ensō + Hanzo H marks; Providers link
- cmd+K: scrolls (mouse + keyboard-follow); docs deep links resolve (/docs)
- Settings: editable org branding (name/logo/colors/theme) with real save
- Linear-style ProductIcon tiles across cmd+K, sidebar, overview headers
- Per-category overview pages (12) + the AI hub
- Observability org-wide on login (Langfuse metrics, no per-project gate)
The Langfuse-derived views (metrics/logs/traces/sessions/scores/observations)
share the o11y REST client. Document + lock the tenancy contract: every call
stamps X-Org-Id (currentOrg(), always set on login) and adds X-Project-Id ONLY
when a project is selected — so these views are ORG-WIDE by default and narrow to
a project only when one is picked. No project required to 'just log in and see
metrics'. Adds a real MetricsModule (org rollups over /v1/o11y + /v1/metrics),
pure metrics logic, and its test. Honest states preserved (loading / not-
initialized 503 / empty) — no fabricated data.
next.config transpilePackages still listed the OLD package name, so Next parsed
@hanzo/dash's shipped TSX source as plain JS → 'Unexpected token' on export type.
Rename the transpile entry, regenerate the lockfile onto @hanzo/dash@0.3.0.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
* feat(base): render Bases under Org → Project (drop the 'tenant' noun)
A superbase tenant IS a full Hanzo Base instance → label the page 'Bases' + show
the Org → Project scope (top-bar ScopeSwitcher) as a breadcrumb, consistent with
every resource module. Passes labels + context to the shared @hanzo/dashboard
screens (props added in superbase feat/base-labels-context). Needs that package
republished + a version bump here to build.
* chore: @hanzo/dashboard → @hanzo/dash@0.3.0 (coherent SDK name) + Base labels/context
Renames the dashboard SDK dep + imports to the canonical @hanzo/dash (was the
incoherent @hanzo/dashboard). 0.3.0 carries the labels/context props so the Base
page renders 'Bases — <org> / <project>' (Org → Project scope) instead of the
'Tenants' noun.
* chore: v8.2.12 — Bases-under-Org via @hanzo/dash@0.3.0 (merge main)
---------
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Dave (maxpower) saw his real $99.74 on mount but it never CHANGED without a
reload — "not seeing increase in the wallet view". The balance READ path was
correct (every surface hits /billing/balance, server-scoped to the caller's
commerce subject = maxpower, the exact subject the gateway debits), but liveness
was missing: SidebarWallet only polled 30s; WalletModule, CostModule fetched on
mount only; nothing refetched on window focus or after a balance-affecting
action. So a completion (debit) or an external billing.hanzo.ai top-up (credit)
was invisible until a manual refresh/reload.
Fix — ONE shared reactive balance store (src/lib/billing/live-balance.ts):
- owns the single /billing/balance fetch (in-flight de-dupe + freshness window
so N mounted consumers cause ONE call);
- refetches on mount, on window focus + tab-visibility (returning from the Square
top-up portal now shows the new balance with no reload), and on ONE ref-counted
30s poll (paused when hidden);
- invalidateBalance() forces an immediate refetch after any balance-affecting action.
useCloudBalance() (useSyncExternalStore) is consumed by SidebarWallet, the Wallet
page cloud-credit card, and the Cost page balance card — every money surface now
shows the SAME live number.
Wire the completion seam: PlaygroundApi.chat (Chat/Playground/cmd-K) and the
streaming runner both invalidateBalance() on a finished completion, and the wallet
top-up success does too — so spend/credit reflects immediately.
Also: /billing proxy responses now send Cache-Control: no-store (a per-tenant
money response must never be cached). And drop the broken self-referential
node_modules 120000 symlink that origin/main re-tracked (it breaks
npm install / vitest / next build locally).
Tests: new live-balance.test.ts (10 — dedupe, freshness, phase mapping,
invalidate, no-flicker). tsc clean, vitest 573/573, next build green (14 pages).
RED's final re-review: double-encoded traversal CLOSED, recommends ship. Two LOW
residuals remained — both inert against the Go backend fleet, but Blue closes them
so the boundary is robust INDEPENDENT of any downstream decode behavior (don't rely
on 'the upstream is Go, single-decodes'):
- R1: N>=3 encoding (%25252e) / overlong UTF-8 (%c0%ae) — pathIsClean now rejects
ANY surviving percent-escape /%[0-9a-f]{2}/i (a legit segment carries none after
Next's single decode; a residual %XX is a multi-encoding/overlong tell). Does NOT
over-reject literal-% names (50%off -> %of, 'o' not hex -> allowed).
- R2: '..;' matrix-param traversal — reject any ';' segment (matrix params unused by
these REST APIs).
The authoritative post-normalization new URL() gate (v8.2.10) is unchanged; this is
the fast defense-in-depth first gate. Non-blocking per RED. RED INFO (/paas + /billing
bypass bearer-proxy) is by-design — they have their own gates (admin+service-token /
billing-scope.ts).
NOTE: local tsc/vitest are down (env upgraded to Node v26 mid-session + shared
node_modules symlink loop); change is a monotonic regex broadening over the
563-test-passing v8.2.10 guard. Validated via CI next build (Node 24) + live (%252e->404,
clean->401 no over-reject).
Each product CATEGORY now has its own landing page at a stable
`/category/<slug>` route (AI, Compute, Data, Network, Security, …) — the
category-level twin of the native product overview. It shows the category name,
an honest one-line description of what the category is, and a grid of every
product in it (each card opening the product's native route). `soon` products
are shown with the existing SOON affordance — never hidden, never faked.
The "AI" overview is the hub for all things AI: it groups Models, Providers,
Inference, Agents, Embeddings, Playground, and Prompts, and leads with prominent
shortcuts to the Model Catalog and Providers.
- Routing follows the SAME pattern as products: one `ProductModule` with a
`:slug` `ProductRoute` (`categoryRouteModule`) in `productModules`, resolved by
the same `resolveRoute` and rendered by the same catch-all. It is deliberately
NOT a `catalog` entry — a category is a grouping of products, not a product, so
it never appears as a card in the nav / home / launcher. Unknown or
out-of-brand slugs `notFound()` (propagated cleanly by ProductErrorBoundary).
- All content is derived from the registry (`visibleCatalogByCategory`, brand-
and admin-scoped) — zero fabricated data. A new product appears on its
category page for free.
- Nav wiring: the sidebar level-1 category headers, the sidebar level-2
"More in <category>", and the catalog-home category headers all link to the
category overview. Breadcrumbs render `Home / Category / <Name>`.
- Pure taxonomy helpers `categorySlug` / `categoryFromSlug` / `CATEGORY_SUMMARY`
in brand-scope.ts (dependency-free), re-exported from the registry; unit-tested
in registry-brand.test.ts (round-trip, unique slugs, complete summaries).
No version bump.
Branding (Settings → Branding was read-only host config):
- BrandingTab is now an editable org-branding form (display name, website,
logo URL, favicon URL, primary color + "apply custom theme") with a real
Save that round-trips the FULL org record to Hanzo IAM via
TeamApi.updateOrganization → the /org/iam self-service proxy. Honest states:
saving / saved / error; a non-admin sees the fields read-only with a gated
notice (the server proxy 403 surfaced as the same honest access message).
No fake success. The prior host-runtime values stay as a read-only
"Runtime (resolved per host)" section.
- Server: /org/iam proxy now allows update-organization (org-admin-only via
requireAdminForWrite), pinned to the caller's OWN org by BOTH the ?id name
and the record's body name (forwardIam), so a brand admin can't retarget
another tenant. admin.ts adds a ThemeData type + Organization.themeData.
Icons (Linear-style):
- New src/components/ui/ProductIcon.tsx — a rounded-square tile filled with the
product's accent color and the glyph knocked out in near-white (the same
raw-hex fill + #fff glyph technique as SwatchButton / ProviderLogo); neutral
$color12/$color1 chip when no color.
- Swapped into CatalogRow (CommandPalette), the sidebar NavRow collapsed +
expanded and its L2 detail header (DashboardShell), and the NativeOverview
product header — each preserving the product's colorOf() accent.
Tests: iam-proxy.test adds org-name body extraction (the parsing the new
update-organization write guard depends on).
RED's re-review confirmed 4/5 fixes closed but found a residual HIGH: pathIsClean
validated the PRE-normalization string, so a double-encoded %252e%252e (Next decodes
once -> %2e%2e, survives the guard) then normalized to real ../ inside undici's URL
parser at fetch time -> reached /v1/get-account, Base _superusers, escaped CLOUD_HEADS
on /cloud /vm /superbase. Live-proven (401 = passed the guard).
Robust fix (validate what fetch ACTUALLY sends):
- forwardWithUserBearer now re-parses the built target URL with new URL() and runs the
AUTHORITATIVE pathIsClean + allow() gate on the NORMALIZED pathname (relative to the
target base), then fetches that normalized dest — so %2e, double-encoding, and any
future encoding are all gated on the exact path undici will request. One-way, DRY:
fixes every helper proxy (/cloud, /vm, /superbase, /tasksd) at once.
- pathIsClean also now rejects %2e (not just %2f) as a fast defense-in-depth first gate
(catches the Next-single-decoded %2e%2e before URL construction).
New tests: 4 double-encoded pathIsClean cases (red today, green now). typecheck clean,
vitest 563/563, next build green. RED re-review of bearer-proxy path validation requested.
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
providers are one AI surface; this makes it easy to get back to providers.
Product modules mount CLIENT-ONLY under the catch-all route (the authed shell
renders a loader during SSR, so the /playground server HTML carries no module
markup — verified). With NO error boundary anywhere in the app, a throw in one
module's first client render bubbled to Next's root fallback and white-screened
the whole console with "Application error: a client-side exception has occurred"
— but only on a DIRECT load / REFRESH; in-app nav re-renders fresh and hid it.
That matches the reported /playground, /prompts, /gpus crashes exactly.
Fix (one place, closes the class for every product route — DRY):
- ProductErrorBoundary wraps the resolved module in the catch-all page. A module
throw now keeps the shell + nav and shows an honest, retryable card instead of
a white screen. Re-throws Next control flow (notFound/redirect/CSR bailout) so
routing still works; auto-recovers a ChunkLoadError (rolling-deploy skew) with
ONE guarded reload (no loop).
- app/(dashboard)/error.tsx: Next-native backstop for throws above the module
(the resolver), rendered inside the shell.
- boundary-logic.ts: pure decisions (chunk detect / control-flow detect / reload
gate), 11 unit tests. Proven end-to-end against a production build: a real
throw renders the card with zero uncaught pageerror (screenshots).
e2e/deeplink-refresh.spec.ts locks the reported scenario: /playground, /prompts,
/gpus (+ controls) must render on direct load AND refresh with no white-screen.
tsc clean · vitest 562/562 · next build green.
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
providers are one AI surface; this makes it easy to get back to providers.
* refactor(auth): call the account surface under /v1/iam/*
Pairs with hanzoai/ai serving signin/signout/get-account/update-preferences
under the organized /v1/iam/ namespace. The client account calls (account.ts)
and the server-side session resolve (identity.ts resolveUser) now target
/v1/iam/*; all remaining references (doc comments) updated for accuracy.
Deploy order: cloud-api (with V1IamRewriteFilter) MUST ship before this, so
/v1/iam/* resolves. No top-level fallback is kept — forward-perfect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cloud): default to cloud.hanzo.svc, drop the cloud-api alias name
The API binary Service is canonically `cloud` (universe renamed cloud-api →
cloud; the cloud-api ClusterIP is a transitional alias). The console2 CR already
sets CLOUD_API_URL=cloud.hanzo.svc, but the server-route code DEFAULTS still
named the dead alias — so any deployment without the explicit env (local dev)
would dial a name slated for removal. Point both defaults (identity.ts,
training proxy) at cloud.hanzo.svc — one name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Command palette (cmd+K):
- The results list clipped instead of scrolling: the body had maxH:420 but the
inner ScrollView had no flex bound. Add overflow:hidden on the body + flex:1
on the ScrollView so the list scrolls (mouse/trackpad) past the fold, and show
the scroll indicator.
- Keyboard ↑/↓ moved selection off-screen with no follow. Tag the active row
(id=cmdk-active) and scrollIntoView({block:'nearest'}) on selection change, so
arrowing always keeps the highlighted item visible. All 50 results are now
reachable and discoverable.
Docs deep links:
- docs.hanzo.ai serves the Fumadocs site under the /docs base path
(docs.hanzo.ai/docs/<slug>), but the console linked bare docs.hanzo.ai/<slug>
→ every 'Full docs' / resource docs link 404'd. Point DOCS (registry) and
docsUrl(kind) (resource/logic) at .../docs. Matches the created product pages.
* fix(vm proxy): default VISOR_URL to visor.hanzo.svc:19000 (was :80)
Cosmetic source fix — the live CR + universe already set VISOR_URL to :19000, which
governs runtime. This corrects the fallback constant so an env-less run also targets
the port visor actually serves (its Service exposes :19000 only; :80 → 502 upstream
unreachable). No version bump; rides the next build (the compute session owns /vm).
* fix(security): RED findings — cross-tenant projects, %2f allow-list bypass, scope headers, error DNS (v8.2.7)
Adversarial review (RED, PR#18/#19) found live holes in the BFF auth path. Fixes:
- [CRITICAL] Cross-tenant project enumeration. /org/iam/get-organization-projects
with an OMITTED/empty ?organization passed the 'validate-if-present' check
(ownerOk(null)=true), and IAM drops its WHERE on empty -> returned EVERY org's
projects (IAM bypasses Casbin for project routes, so the proxy is the only gate).
CONFIRMED LIVE (Dave/maxpower saw dhd, 7stars-dev projects). Fix: forwardIam now
PINS ?organization to the caller's orgScope for a non-global admin on org-keyed
segments (pinnedSearch, server-authoritative like X-Org-Id); global admins
unrestricted. Org-keyed WRITES must carry owner+organization == own org (no
owner='' junk rows). New orgParamSegments={get-organization-projects,add/delete-project}.
- [HIGH] %2f + .. allow-list bypass on /cloud, /superbase, /tasksd. Next decodes
%2f into segments without re-normalizing dot-segments, and fetch() collapses '..'
AFTER the allow-list -> 'functions%2f..%2f..%2fiam' slipped a foreign head past
allowCloudSurface. Fix: forwardWithUserBearer now rejects any '', '.', '..' or
surviving %2f segment (pathIsClean) BEFORE the allow-list, and trims trailing
slashes. One guard fixes every helper-based proxy; /ai was already immune (exact-match Set).
- [MEDIUM] Dropped forwardScope on /cloud + /vm — no longer forward browser-controlled
X-Project-Id/X-Environment (org is authoritative via the Bearer owner; the resources
are org-keyed). A project-scoped feature must validate membership first.
- [LOW] Redacted 502 bodies (were leaking internal svc host/port) -> generic message +
server-side console.error. Both bearer-proxy and iam-proxy.
- [bundled] /vm default VISOR_URL -> visor.hanzo.svc:19000 (was :80; visor serves :19000).
New pure tests: pinnedSearch (4), pathIsClean (3). typecheck clean, vitest 551/551,
next build green. Re-review by RED requested.
Advance prod (operator-pinned v8.1.1) to current main: KMS secret CRUD, IAM
Users CRUD, IAM Roles CRUD, 89-page e2e harness. Build publishes
ghcr.io/hanzoai/console2:v8.2.7; operator CR bump follows once the image lands.
Cuts a release so prod (operator-pinned) can advance from v8.1.1 to include:
- KMS secret management (create/reveal/delete)
- IAM Users CRUD (create/promote/delete)
- IAM Roles CRUD (create/delete)
- 89-page screenshot e2e harness
All auth via /v1/iam/* + cookie-session /v1/*. Build publishes
ghcr.io/hanzoai/console2:v8.2.7.
IAM already serves /v1/iam/{add,update,delete}-role (iam controllers/role.go) —
I was wrong that it needed a backend. Wire it: allowlist the 3 role mutations in
the /admin/iam proxy, add addRole/updateRole/deleteRole to IamAdminApi, and give
the Roles tab a create/delete view (RolesAdminView, mirrors UsersAdminView).
All auth flows through /v1/iam/* per the one-path rule. Typecheck clean.
IAM is now fully CRUD in-console: Orgs (list), Users (create/promote/delete),
Roles (create/delete), Applications + Providers (add/edit/delete) — no link-out
for the common lifecycle.
Projects rendered 'not routed' because ProjectApi hit the cloud /v1 cookie path
for IAM endpoints: console.hanzo.ai/v1/iam/get-organization-projects → the gateway
sends /v1/* to the CLOUD binary, which does NOT serve IAM → 404. Proven live:
that exact URL returns 404, while IAM serves the endpoint.
Fix (same BFF-Bearer pattern, existing infra — no new mega-router):
- projects.ts now calls the same-origin /org/iam proxy via makeIamClient, which
mints a user-bound Bearer server-side and forwards to iam.hanzo.svc. Org resolves
from the token owner claim (per-tenant), the member-roster pattern.
- /org/iam allow-list gains get-organization-projects (GET) + add-project,
delete-project (POST, org-admin only via requireAdminForWrite).
- SECURITY: forwardIam now also pins the ?organization param AND the body
organization field to the caller's org (ownerOk), closing the cross-tenant gap
for the projects lister/CRUD (ownerOk(null) is a no-op for segments without it,
so no regression to get-users/get-roles). bodyOwner generalized to bodyField.
typecheck clean, vitest 505/505 (+4 new: projects.test.ts, iam-proxy.test.ts),
next build green. Auth-mint/proxy path touched → hand to RED.
The ai data-scoping filters (GetEffectiveOrg, controllers/org_resolver.go)
and the provisioning sub-service both read `X-Org-Id` — NOT `X-IAM-Org-Id`.
cloud mints X-IAM-Org-Id OUTBOUND toward commerce from the validated
principal, so the browser stamp was inert dead weight (and the old comment
claiming GetEffectiveOrg reads X-IAM-Org-Id was drift). Keep the required
X-Org-Id stamp; drop X-IAM-Org-Id; note X-Project-Id is the canonical
project sub-scope evalsvc now reads.
The data + serverless surfaces (vector/sql/kv/s3/docdb/datastore/search,
functions/prompts/agents) and visor compute now resolve org from the Bearer
JWT owner claim; a cookie-only browser call 403s ('X-Org-Id required'). The
console BFF now mints a short-lived user-bound IAM token server-side (the
proven /ai + /keys pattern) and forwards it, so the browser path works
end-to-end, per-org — no token in the browser, org never browser-supplied.
- src/lib/server/bearer-proxy.ts — ONE shared forwardWithUserBearer(req, opts):
resolveUser (session cookie) -> adminBearer (shared per-user token cache in
identity.ts) -> forward with Authorization: Bearer + X-Org-Id=owner, cookie
NEVER forwarded (dodges the public-gateway 431), response STREAMED (SSE/JSON/
204). Pure errorBody/upstreamHeaders + proxy-allow.ts allow-lists, unit-tested.
- app/cloud/[...path] — user-bearer proxy to cloud-api for the data + serverless
heads (allowCloudSurface); provisioning.ts + functions.ts repointed to
<origin>/cloud/v1/* (was the cookie-only direct path that 403s).
- app/vm/[...path] — user-bearer proxy to visor (allowVisorSurface) for the
compute surface (regions/gpus/machines); ready for the compute UI.
- DRY: /ai, /tasksd, /superbase refactored onto the shared helper — 3 duplicate
issue-user-token caches deleted, one adminBearer cache for every proxy.
typecheck clean, vitest 464/464 (+14 new), next build green (/cloud + /vm compiled).
Co-authored-by: Hanzo AI <ai@hanzo.ai>
* feat(console2): make all 14 external products native in-console overviews
The registry declared 14 products with kind:'external' (gateway, dns, cdn,
mpc, cli, sdks, api, ide, desktop, registry, metrics, crawl, studio, console),
but open.ts / match-core / NativeOverview / overviewFor were already collapsed
to a no-external world. Result: those 14 pushed /${id} which resolveProductView
returned notfound → HARD 404 dead links from the overview grid + app launcher.
Convert each external entry to kind:'module' routes:overviewRoutes(id), rendering
the existing NativeOverview (bespoke OVERVIEW_SPECS already merged — real header,
live platform-app health, key facts, native actions, INLINE docs, zero link-out).
Collapse CatalogEntry union to module-only and ProductStatus to enabled|soon;
remove the dead external branches in DashboardShell + OverviewModule and the stale
ext/href config. One way to open anything: a native route.
resolve.test.ts already pins all 14 specs + native-route actions; match-core.test
updated to assert the kind-guard fails closed for a non-module entry.
typecheck 0 errors, 381 vitest pass.
* feat(console2): Subscriptions + Payment Methods + Marketplace pages (real feeds)
Three new production-complete control-plane pages, all backed by REAL /v1 data,
matching the console taxonomy — no new backend needed (they ride existing feeds).
Billing sub-pages (category Observe, alongside cost/plans):
- SubscriptionsModule → GET /v1/billing/subscriptions (commerce, via the /billing
per-tenant proxy: server-injected COMMERCE_TOKEN, org-scoped, client can't widen).
Plan / status / seats / price / renewal. Read-only; manage links to the portal.
- PaymentMethodsModule → GET /v1/billing/payment-methods. Card-data MASKED by
construction: normalizer extracts only brand+last4+expMonth/Year+isDefault — a
PAN/CVV/token in the payload is dropped and never reaches the display object
(dedicated leak test asserts it). Renders '•••• last4'. Read-only; add links to portal.
- billing.ts: Subscription+PaymentMethod types + normalizeSubscriptions/
normalizePaymentMethods (handle Stripe snake_case AND camelCase, nested card obj,
Unix seconds/ms dates). billing.test.ts: +9 tests incl the PAN/CVV/token leak guard.
Marketplace (category Apps, alongside chat/bot/search):
- MarketplaceModule → the storefront over the REAL model catalog (aicatalog.
fetchCatalog → GET /v1/pricing/models via the authed /ai proxy). Category tiles,
featured shelf (real catalog flag), filterable listings w/ real per-Mtok pricing,
Try-it→Playground CTA. Reuses the existing aicatalog client + ProviderLogo — a
distinct storefront view over the SAME catalog, not a duplicate of Model Catalog.
- marketplace/logic.ts (categorize/featured/applyFilters/marketStats) + 16 tests
incl a regex-injection safety test (search is a literal substring filter).
Hardening across all three: read-only, org-scoped (IDOR-proof), no secrets in the
bundle, XSS-safe (plain <Text>, no dangerouslySetInnerHTML), honest loading/empty/
error states (BackendStateCard/ErrorState), every number a real field or '—' —
nothing fabricated. Tamagui shorthand only, dark design language.
typecheck 0 errors · 406 vitest pass (+25 from the new suites).
* docs(console2): document the all-pages build in LLM.md (external→native + billing/marketplace + honest scope)
* fix(console2): billing-proxy tenant isolation — X-Org-Id + full subject-key pinning (RED HIGH)
RED found the /billing proxy's tenant scoping was INERT — a cross-tenant IDOR:
1. It stamped X-Hanzo-Org, but commerce reads X-Org-Id on the service-token path
(commerce/middleware/accesstoken.go) — the header silently fell back to the
service org, so every tenant shared one commerce namespace.
2. It pinned only ?user=, but subscriptions filter ?userId=
(commerce/api/billing/subscriptions.go) — with no userId the query returned
EVERY subject's subscriptions in the namespace (cross-tenant read).
Fix, mirroring commerce's own edge-auth exactly:
- Send X-Org-Id (like the exemplary /ai proxy) so the namespace resolves per-tenant.
- Pin the FULL subject-key set {user,userId,customerId} to the server-resolved
subject (= commerce/middleware/edgeauth.go billingSubjectKeys) so NO billing
endpoint is left unfiltered whichever param it reads; ?org= is still dropped.
- Extract the scoping to a pure src/lib/server/billing-scope.ts (scopedBillingSearch
+ billingSubject) — testable without the Next runtime, same pattern as ai-proxy.ts.
- Defense-in-depth: normalizePaymentMethods clamps last4 to the last 4 digits even
if commerce puts a full PAN there.
Tests: billing-scope.test.ts (11) — client-forged-subject overwrite, two-tenant
disjointness, non-subject passthrough; billing.test.ts +1 (last4 PAN clamp);
e2e/billing-isolation.spec.ts — live two-tenant disjoint subscription/payment sets;
the 3 new pages added to the 89-route render pass. typecheck 0, 487 vitest, next build ✓.
Rebased on latest main (living-overview #17).
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Per the 'one /v1, no extraneous prefix' rule: the Networks module now calls
same-origin /v1/networks (gateway-routed to the bootnode control plane), not a
per-backend /bootnode/* proxy. Deletes app/bootnode. Honest states unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The admin Platform Overview is now a REUSABLE `LivingOverview` component system
(one component, many configs), not a one-off. Videogame-like: count-up KPIs, live
sparklines, a streaming/virtualized activity feed, throttled polling — tasteful,
60fps, reduced-motion-guarded. Backed by REAL /v1 data, no mocks; every missing
feed renders its honest empty/skeleton/em-dash, never fabricated numbers.
System (src/components/products/overview/living/):
- config.ts declarative LivingOverviewConfig: tiles in rows + one real-data
load() => OverviewData + a live block (pollMs/countUp).
- motion.ts pure count-up/sparkline-ring/poll-clock math (unit-tested).
- hooks.ts thin rAF/interval drivers (useCountUp animates from the current
on-screen value on retarget; usePoll/useReducedMotion/usePageHidden),
all self-cleaning — no leaked frames/timers.
- logic.ts pure tile decisions: unit-aware formatMetric, deltaOf ("—" w/o basis),
hasTrend, status/health colors, mergeActivity (stream dedupe),
windowRows (virtualization), worst/tally (unit-tested).
- tiles.tsx the 6 animated tiles (reuse ui/Charts verbatim; skeleton/empty/error).
- LivingOverview.tsx the driver: one throttled poll loop (5s floor, paused when
hidden/errored), reqRef race guard, background refetch never blanks a
board with real data. globals.css: hz-skeleton/hz-pulse/hz-row-in.
Real data (adapters.ts, pure + tested): fromCloudUsage (commerce usage ledger),
fromAdminOverview (new lib/api/admin-overview.ts — /v1/admin/overview, optional-safe,
degrades to honest empty on 404), fromFunctions, healthFromApps (operator inventory).
Wired (overview/living/registry.ts — declarative catalog): overview (platform
centerpiece at / and /overview; admin aggregate w/ honest fallback to usage+health),
ai-metrics, functions, gpus. Product route '' renders livingOverviewModule(id); tabbed
products keep :tab, reachable via the sidebar sub-nav (declared subpages) — no dead-end.
Adding a product overview = one config, no UI.
Deletes the superseded OverviewModule + AiMetricsModule (+ aimetrics/{StatTile,
UsageChart,format}) — one overview system, DRY.
typecheck clean (0 errors), 449/449 tests (42 files), next build green (14/14).
Visual proof via headless Playwright: full board renders w/ count-up + live sparklines
+ streaming + donut + health tally, values change across a 5s poll, reduced-motion
snaps to real values, functions/gpus render honest empty/error states w/o crashing.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Drives a signed-in session over every registered product route, asserts each
mounts without a hard crash (no Next error overlay, non-blank body, <500), and
captures a full-page screenshot to e2e/screenshots/<id>.png. The 'screenshot it
all, verify every page is wired FE↔BE' pass. Gitignores screenshots/test-results.
Running it against live console.hanzo.ai surfaced a P0: the cloud /v1 backend is
returning 502/503, so sign-in (/v1/signin) fails for all users.
IamModule's Users tab was read-only + linked out to the external casdoor console,
but IamAdminApi already has add/update/delete-user (and the /admin/iam proxy
allowlists those mutations). Surface them: create user (name/email/password,
argon2id-hashed by IAM), promote/demote global-admin (shield toggle), and delete
— all scoped to the active org through the server-gated proxy. Honest states
(loading / operator-required 403 / error / empty). Typecheck clean.
The KmsModule listed metadata and linked OUT to the standalone kms.hanzo.ai
console for everything else. But the /admin/kms proxy + KmsAdminApi already
support full CRUD (list/reveal/create/rotate/remove) over /v1/kms/orgs/{org}/
secrets. Surface it: create/upsert form (path/name/env/value, secure), per-row
reveal (one value, shown once, audited, never cached/listed), and delete — all
through the server-gated admin proxy, scoped to the active org. Keeps the
zero-knowledge stance (no bulk value listing). Removes the external link-out, so
console2 is the single KMS management surface. Typecheck clean.
Adds 4 credential-free live tests proving production posture: root serves
(200/dark #0a0a0a) + /base resolves; server proxies (superbase/bootnode/keys)
reject unauth with 401; proxy allow-lists reject off-list paths with 404 (no
tunnel); unknown route never 5xxs. 5/5 green vs console.hanzo.ai — real CI signal
without the prod superuser password.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the taxonomy + per-brand scope into dependency-free src/lib/products/
brand-scope.ts (pure, brand-passed-in) so it's unit-testable without hostname
mocking or loading the React-heavy registry. registry re-exports it (no API
change). New registry-brand.test.ts PROVES: hanzo=all 12 categories; lux/zoo/
pars=ONLY Web3/Network/Security/Dev/Settings (web3/bootnode), hiding every
AI-cloud category; Networks(Web3) surfaces on lux/zoo. 12 tests green, 381 total,
typecheck + next build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The core bootnode ('Web3 Backend in a Box') primitive, ported into console2 so
the lux/zoo web3 consoles manage real blockchain networks (chain/nodes/status/
RPC). Reads the live bootnode control plane via a new per-user /bootnode proxy
(mints the user's IAM bearer; least-privilege to the networks surface + launch/
rpc/scale), rendered with @hanzo/data's DataTable. Honest states on 401/404/503.
Retires the old bootnode-admin app — one console (hanzoai/console), brand-scoped.
typecheck + build + 352 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two customer-facing bugs found in a live real-tenant e2e audit of
console.hanzo.ai, both making the "backend-aware FE" partially broken.
BUG 1 — Overview usage/spend widget showed no spend.
The Overview called cloud `/v1/get-cloud-usages`, which returns 200 with
{"status":"error","msg":"usage ledger unavailable: datastore peer not
connected"} — the cloud usage-ledger's o11y datastore peer is down. Repoint
`UsageApi.overview()` at the REAL commerce ledger `/v1/billing/usage` (via the
existing per-tenant `/billing/*` proxy — the SAME source the Cost page and the
gateway debit against, X-Hanzo-Org scoped). New pure `usage-adapter.ts` rolls
the raw records up into the rich `CloudUsageOverview` the dashboard already
renders (totals, prior-period deltas, dense time series, top-N + Other
spend-by-model, paginated inference activity) — reusing the ONE canonical
aimetrics parse, so Overview and Cost agree to the cent. Overview UI unchanged.
Verified live: commerce returns 158 real records for hanzo/z; cloud ledger is
dead. `UsageRecord` gains additive premium/stream/status/requestId (from the
commerce metadata) for faithful activity rows.
BUG 2 — API Keys never listed a minted key (uncopyable/unrevocable, re-minted).
`POST /keys` mints a real working hk- key (User.AccessKey in IAM), but
`GET /keys` derived hasKey from the cloud `get-account` session claim, which
returns accessKey='' for a freshly-minted key → the page reverted to the empty
"Create" state on reload. Read the key AUTHORITATIVELY from IAM
`get-user?id=<owner>/<name>` (new `getUserKey` server helper) instead. Verified
live: IAM holds z's accessKey=hk-eeedb378-... while get-account returns ''. The
key now lists (prefix + last created/rotated date) and revoke works.
- No mocks/fixtures; honest states preserved. Coordinated admin/per-host/ingress
files untouched. tsc clean, 369 vitest pass (22 new adapter tests), next build green.
console.hanzo.ai = full AI cloud; console.lux.cloud / console.zoo.cloud /
console.pars.* = web3/bootnode admin only (Web3 + Network + Security + Dev +
Settings — on-chain, networks/nodes/peering, keys/HSM/authz, dev keys, org).
ONE knob: BRAND_CATEGORIES in registry, filtered at the single catalog-consumption
point (visibleCatalog/catalogByCategory/visibleCatalogByCategory) via inBrand +
brandCategoryOrder. Settings already shows brand-resolved info (brand/name/IAM/
billing per host). Hanzo unchanged. typecheck + 345 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On an admin console host (admin.hanzo.ai) resolve the OAuth client to the
admin-org app `admin-console` so IAM login resolves the global-admin identity
(owner=admin); every normal host keeps the brand cloud client (hanzo-cloud).
Isolated to src/config: `adminApp` per brand (ONE global admin-console app for
the reserved admin org), `isAdminHost()`, and a HOST-keyed cache (admin.hanzo.ai
and cloud.hanzo.ai are the same brand but must resolve to different clients — a
brand-keyed cache would collide). iamAppName + iamClientId travel together;
NEXT_PUBLIC_* overrides still win. Composes with the admin-mode UI (untouched).
+7 vitest (admin/normal host resolution, cache isolation, strict admin. prefix);
tsc --noEmit clean.
The whole suite gated on HANZO_PASSWORD, so CI got zero signal without the prod
superuser secret. Split the credential-free sign-in render check into its own
describe so it always runs — asserts email/password + GitHub/Google + passkey.
Verified green against live console.hanzo.ai. Authenticated flows still gate on
HANZO_PASSWORD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shell + information architecture (owns DashboardShell, nav, header, registry
category/sub-page contract, ⌘K, Team/Settings/Profile). No product content
modules touched (referenced by id).
1. Two-level sidebar nav (Linear-style). Level 1 = categorized product list;
clicking a product slides into its sub-nav (level 2) with a Back affordance;
the open product follows the route. CSS-transform slide (.hz-slide,
reduced-motion aware).
2. Uniform sub-page contract. Every product gets Overview · Settings · Status ·
Logs · Metrics plus its declared specifics (productSubpages, match-core).
A sub-page with no backend renders an honest ProductSubpageStub — never a
404, never fabricated.
3. ⌘K jumps to ANY level. searchDestinations indexes products + declared
specifics ("queues" → Compute › Tasks › Queues).
4. Category restructure. Deploy→Platform; new Training (Fine-tuning + ML
Pipelines) and Settings (Team/Settings/Profile); Compute gains
Kubernetes/Clusters/Tasks; Async category + Jobs entry KILLED (Tasks
replaces Jobs). Dev/Web3 retained (real products).
5. Sidebar chrome. H mark only (no wordmark), no in-sidebar collapse toggle
(moved to header), animated collapse (.hz-collapse, 264↔64).
6. Header cleanup. Removed user name + Sign out from the header; Sign out now in
the footer wallet under Top up; footer user row → Profile; kept org/project/
network(env) switchers + theme + help + notifications(bell→/alerts).
7. Team (org member mgmt: list/invite/role/remove + read-only Roles), org
Settings (General/Branding), Profile (Account/Security/API Keys). Member
mgmt runs over a NEW org-scoped /org/iam proxy so an ORG admin (not only a
global admin) manages their own org — tenant-isolated server-side, with the
body-owner cross-tenant write gap closed in a shared forwardIam (also
hardens /admin/iam). DRY IAM envelope client (iam-envelope).
Admin↔customer surface gating (systemic): admin-only products (Providers, IAM,
KMS, Secrets, Audit, Clusters, Kubernetes) + admin sub-pages (Models › Routing)
are hidden from a customer's nav/launcher/⌘K and render a graceful "Managed by
Hanzo" notice on direct access instead of a hostile 403. Signal = global-admin
(isGlobalAdminAccount, DRY with OrgGate). Customer surfaces (Models browse,
Playground, Chat, API keys, Cost, Team, Compute/data) fully work per-org.
Network×org×project scope was already first-class (client.ts stamps X-Org-Id +
X-IAM-Org-Id + X-Project-Id + X-Environment; ScopeSwitcher env picker) — kept
as-is.
Verify: tsc clean · vitest 308 (incl. new sub-page/routing/destinations/org-
policy tests) · next build green (/org/iam route compiled) · Playwright desktop
+ 390px mobile, both personas.
Four resource consoles wired to REAL backends with honest states (never fabricated).
- Kubernetes (Compute): real DOKS clusters via PlatformApi (/paas → platform);
stat cards + cluster table with provisioned CPU/RAM DERIVED from node-pool slugs
(honest "—" for GPU/unknown); Import (honest) + Create → real provisionCluster.
- Containers (Compute): apps-inventory Workloads + Pods/Images/Namespaces/Events as
:tab sub-routes over /paas (tolerant shapes, honest states); cluster-detail sidebar.
- Training (AI/Fine-tuning): live mlsvc /v1/train/{jobs,experiments} + /v1/ml/models
via generalized /training proxy; stat cards, jobs table, training-loss chart,
checkpoints, models, configs; New-job panel (base model from real aicatalog) →
POST /v1/train/jobs (402 ResourceMeter surfaced honestly); Save-as-config.
- Tasks (Compute): Temporal console over hanzoai/tasks tasksd via NEW /tasksd
minted-Bearer proxy; Workflows/Schedules/Queues/Workers/Activities :tab sub-routes;
stat cards; workflow detail panel + step graph + 8 sub-tabs; engine-health /
throughput / queue-util / recent rail. Public tasks TLS not live yet → honest
states until the in-cluster engine is reachable.
Registry: removed the Async category + the Jobs entry; Tasks & Kubernetes → Compute.
Shared: promoted console primitives to ui/Metric.tsx (DRY; gpus/charts re-exports);
nodes.ts capacity derivation (+12 unit tests). tsc 0 / vitest 304 / next build green.
Fixes Dave's live-Playground complaints on console.hanzo.ai/playground.
- ModelPicker: rebuilt as a keyboard-navigable provider→model CASCADE (Zen-first
provider rail + model pane with real context badge + $/Mtok + live dot;
searchable; free-text fallback). New pure providers.ts (Zen-first grouping) +tests.
- ResponsePanel: render the completion as real markdown (new pure markdown.ts
tokenizer + MarkdownView: fenced code blocks w/ copy, inline code, bold/italic,
lists, headings, links) instead of plaintext; add a clear Stop control in the
panel header during streaming (AbortController was already wired end-to-end).
- history.ts: namespace per user (owner/name), auto-persist every completed run
so History auto-populates; one account never sees another's runs (+tests).
- ModelSettings: consolidated into a collapsible side-pane ATTACHED to the prompt
builder (desktop) and a bottom sheet on mobile (new SettingsSheet, reusing the
shell Dialog drawer pattern); shrink the oversized slider thumb + thin the track.
- ChatPlayground/Composer: responsive 3-zone layout stacks cleanly at ~390px with
no horizontal scroll; a settings toggle opens the desktop pane / mobile sheet.
Verified: tsc --noEmit clean, vitest 311/311, next build green; Playwright desktop
+ 390px (cascade, keyboard nav, stop, markdown, mobile stack, settings sheet).
Wallet "Top up" deliberately NOT rerouted to pay.hanzo.ai: it is ALSO broken
(GET /v1/commerce/tenant -> 404 "unknown tenant", so the Square Web Payments SDK
never initializes). Breakage not moved; root cause reported for a backend fix.
* feat(console2): native control planes (zero external link-outs) + Hanzo Functions dashboard
Three deliverables, one PR, all over the one /v1 surface.
1) No external link-outs (priority). The catalog's `external` kind is removed:
CatalogEntry is module-only, ProductStatus is 'enabled' | 'soon'. The 14
products that used to open another domain (Gateway, DNS, CDN, MPC, CLI, SDKs,
API, IDE, Desktop, Registry, Metrics, Crawl, Studio, Console) are now native
in-console routes rendering ONE shared NativeOverview (overviewFor(id) +
overviewRoutes(id), the DRY twin of soonRoutes): header + summary, a REAL
health band (probes PlatformApi.apps() with honest not-deployed/not-reporting
states), key-fact cards, native-route actions, and INLINE docs. Content is a
pure OverviewSpec per product (overview/spec.ts + resolve.ts, with a
catalog-derived defaultSpec fallback). The external branches in open.ts,
DashboardShell, AppLauncher, CommandPalette, ProductInterstitial, and
OverviewModule are removed.
2) Hanzo Functions dashboard. FunctionsModule rebuilt into a tabbed product
(Overview · Functions · Deployments · Triggers · Secrets · Settings) over the
rich lib/api/functions.ts (GET /v1/functions*). Branded "Hanzo Functions" with
the honest Fission engine badge. Overview: 6 KPI cards derived from real rows
(deriveOverview, honest "—"), real-series sparklines + trendPct deltas, an
"Invocations over time" LineChart with 1H/6H/24H/7D/30D toggles, an "Invocation
status" Donut, and the shared FunctionsBrowser (table + DetailRail). Secrets is
names-only. Reuses functions/{FunctionsTable,DetailRail,parts}.tsx unchanged.
3) Overview "Explore products" drops the enablement gate: no more
Enabled/External/Soon badge; every product is open-for-all with Open (native) +
a "Learn more" affordance to the native /discover/:id interstitial.
Idiom: strictly @hanzo/gui v5 shorthands. New tests: overview/resolve.test.ts.
npm run typecheck clean (0 errors); npm test 298/298 (31 files); every route
compiles + 200s on the dev server.
Drive-by: remove the bogus tracked node_modules self-symlink blob that broke
npm install/vitest (.gitignore already ignores node_modules/).
* docs(console2): fix overviewFor comment reference
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
The arcd runner can't pull ghcr.io/hanzoai/nodejs:24-alpine (403) — broke every
build since v0.7.32. Use the public ECR Docker-library mirror (no auth, no rate
limit), the v0.7.9 pattern; align the workflow pre-pull to 24-alpine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire published @hanzo/data@1.1.0 (peer @hanzo/gui 7.3.0) into the Base UI
as a composable, honest collection viewer.
- next.config.mjs: transpile @hanzo/data (ships TSX source, like @hanzo/gui)
- src/lib/base-data/fields.ts: pure baseCollectionToFields() mapping a Base
collection schema -> @hanzo/data FieldDefinition[]. Covers text/number/bool/
email/url/editor/date/autodate/select+multiSelect/json/relation/file/geoPoint;
skips hidden+system fields (keeps id); handles modern `fields` and legacy
`schema`/nested `options`.
- src/lib/base-data/api.ts: tiny BaseDataApi over a Base /v1 (listCollections,
listRecords) -- raw REST + optional bearer, shares the app's typed ApiError.
- src/components/base-data/CollectionTable.tsx: client component; schema ->
fields -> records -> @hanzo/data DataTable with honest
loading/empty/error/not-found states (no fabricated rows).
- src/lib/base-data/fields.test.ts: 10 vitest cases for the mapping.
Verify: `npm run typecheck` clean; `npm test` 58/58.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Base product module rendering the shared @hanzo/dashboard screens (published
on npm) via a per-user /superbase proxy (mints the user's IAM bearer); catalog
entry + page /base · /base/new. Plus the Base look: black #0a0a0a surface
aligning to the zinc-on-black identity. Consumes @hanzo/dashboard@0.2.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polish the Chat UI without touching any data wiring — Chat still calls the
real /v1/chat/completions through the keyless /ai proxy, and every state/API
call, prop, and honest empty/down state is preserved.
- Bubbles: assistant turns read as open text with a sparkle medallion +
name + timestamp and comfortable line-height (dropped the heavy bordered
card); the user turn is a refined right-aligned accent bubble. Both render
light markdown — fenced code blocks (monospace, tinted card, optional lang),
inline code chips, and bold.
- Markdown: new dependency-free renderer (chat/markdown.tsx) reusing the
existing `fontFamily: 'monospace'` idiom — no heavy remark/rehype tree added
(console2 ships no markdown lib).
- Welcome/empty state: sparkle avatar + "How can I help?" + 3–4 clickable
suggested-prompt chips that fill the composer on click.
- Composer: one rounded, elevated input with a code-insert ({}) and a circular
send affordance (hover/press states) over a subtle muted hint row.
- ChatView: read-only history thread matches the new bubble look + markdown.
- ChatListView: name cell reads as a link (weight + hover); table unchanged.
All Tamagui shorthands (bg/maxW/rounded/items/justify/self/p/px/py/gap) per
onlyShorthandStyleProps. tsc --noEmit clean; 245 vitest tests pass.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Redesign the Playground from the multi-model compare board into the polished
3-zone surface in the mockup, reusing the existing AI binding (runner/stream/
PlaygroundApi) — nothing about model output, tokens or cost is fabricated.
Layout (per mockup):
- Header "Playground" + subtitle + Save prompt / </> code / Share actions.
- Tabs: Chat · Completions · Embeddings · Audio · Vision.
- Composer (left): model chip with a REAL "context" badge + searchable picker
over the live catalog (aicatalog.fetchCatalog, 375 models), System prompt +
user/assistant turns with char counts, and a footer of Add message / Upload
(image→vision) / {} Variables / Run (⌘↵ + caret). Completions collapses to a
single Prompt card.
- Examples (labelled starters w/ model chips) + History (real local prior runs,
honest-empty) beneath the composer.
- Right rail: Response/Logs panel (real completion, USAGE tokens + COST from the
model's real $/Mtok, "—" when absent) and Model settings (Temperature 0.7,
Top P 0.9, Max tokens, Stop, Advanced: freq/presence penalty + seed).
Real behaviors, no backend: Save prompt (local library → Examples), Code (real
cURL/JSON request preview), Share (encode composer into ?p= link, restore on
load), {{variables}} substitution, ⌘↵ to run.
Wiring: Temperature/top-p/max-tokens/stop + advanced penalties/seed flow through
paramsOf into the request (sent only when set). Removes the compare-only files
(ComparePlayground/CompareColumn/AddModel/useCompare/SettingsControls).
Tests: +37 unit tests (params/variables/share/prompts/request-preview/compose/
relative); 282 total green. tsc clean, next build green.
The OrgSwitcher collapsed to a STATIC non-clickable label whenever the admin-gated
org list returned empty (every tenant: /admin/iam is global-admin-only → 403), and
there was NO create-org affordance. Now: the trigger is ALWAYS an interactive
Popover (current org always shown + filter + switch), with a 'Create organization'
flow that posts to /onboard and scope-switches into the new org. /onboard relaxed:
an existing-org user can create an ADDITIONAL org (created WITHOUT moving them — a
move would strip a global admin's status + orphan their current org); zero-org
first-run still creates+joins. v0.7.31
Decomplect the shared resourceModule factory (backs all managed-data kinds)
into ONE spec-driven console, upgrading every data page at once:
- resource/logic.ts pure per-kind ResourceSpec + fleet/status/endpoint/
snippet helpers (RESOURCE_SPECS, 16 unit tests). The list API carries
lifecycle facts only, so usage metrics are honest "absent" (null -> "—"),
never fabricated.
- resource/parts.tsx ResourceStat (honest "—" + "Awaiting metering"),
StatusDonutCard, TabBar, SnippetBlock, CopyField, SectionCard.
- resource/ResourceListView.tsx the polished home console: REAL fleet stat
row + tabs (Overview / <instances> / Access / Metrics / [tool] / Settings)
+ right rail (status donut, quick actions, quick start) + the real create
flow (POST /v1/<kind>, password shown once).
- resource/ResourceInstanceView.tsx tabbed single-instance console
(Overview / Access / Settings) with a real confirmed delete.
- ResourceModule.tsx thin router; same resourceModule/resourceRoutes
exports -> zero registry churn, all 5 graded kinds (+docdb/search) tuned.
Honest by construction: status breakdown from real lifecycle, create/delete
real, the data-plane tool tab (Query/Browser/Explore) is an explicit
"coming soon" with the real connect snippet — never a fake success.
Gates: tsc --noEmit clean, vitest 245 pass, next build 14/14. Bump 0.7.30.
One spine for the Models, Providers, and (later) hanzo.ai / @hanzo/dev / desktop
surfaces, all read through the authenticated /ai proxy. Real data, honest states.
aicatalog (the spine):
- Joins the TWO real catalog record shapes — first-party Zen (`context`+`name`)
and third-party (`contextWindow`+`id` like openai/gpt-5). Fixes context showing
"—" on 339/375 models and cross-references availability by the stable id.
- Adds priceBucket/matchesQuery/modelTypes/CONTEXT_BUCKETS filters, the provider
`verified` flag, modelDisplayName (strips "OpenAI: " row prefixes), and
fetchPlans() reading the real /v1/plans subscription tiers (rpm/tpm/quota),
honest-empty on 401/404.
Components (self-contained, prop-driven, liftable to @hanzo/ui):
- ProviderLogo — monochrome avatar resolved by provider name: first-party Sparkles
mark, curated known-provider glyphs, initials chip otherwise. No external URLs.
- SelectMenu — reusable Popover dropdown for the Providers filter row.
Models page: ProviderLogo in rows + provider column, search box, collapsible
type/provider/pricing filters, stacked in/out pricing, real context via the join,
and subscription-plan badges in the detail (model tier -> /v1/plans limits).
Providers page: Explore / My Providers / Custom Models / BYO Weights tabs (the
latter three honest, pointing at the real add/deploy flow), search + type/context/
pricing/verified/sort row, ProviderLogo + Verified badge on cards and detail.
/ai proxy: allow v1/plans through the authenticated proxy (least-privilege add).
Overview: already at parity on the real usage ledger — verified, not rebuilt.
Gates: tsc --noEmit clean, 230/230 vitest (incl. 12 new aicatalog tests),
next build green (14/14). Bump 0.7.28 -> 0.7.29.
Re-pin off public.ecr.aws (rate-limited builds + violates ghcr-only). nodejs is the
Node.js runtime mirror (hanzoai/node is the blockchain node). COPY-before-install
(the real Kaniko --single-snapshot fix) is preserved.
- default SSR/build host -> cloud.hanzo.ai (was console.hanzo.ai); brand still
resolves by hostname suffix, so console.hanzo.ai/console2.hanzo.ai (which now
301 to cloud.hanzo.ai at the gateway) still map to brand hanzo.
- cloudUrl() stays SAME-ORIGIN: the SPA calls cloud.hanzo.ai/v1, and the gateway
routes that /v1 to the cloud package (global IAM-JWT + rate-limit) — so
"everything goes through the gateway" holds with the cookie first-party (no CORS).
- console product + experiments external links -> cloud.hanzo.ai.
The v0.7.26/27/28 image builds 403'd: today's base-image thrashing pointed the
Dockerfile at ghcr.io/hanzoai/nodejs:24-alpine, which is PRIVATE — the host
builder's ghcr login is scoped to console2's own package and can't read the
separate nodejs package (403 Forbidden on the base pull).
build-image.yml still pre-pulls + caches public.ecr.aws/docker/library/node:
22-alpine (its comment even says the Dockerfile uses it). Re-align the Dockerfile
to that public base: no auth, no 403, and the warm ARC runner serves it straight
from the cache the pre-pull step populates. node 22 is the version v0.7.25
shipped on. The Kaniko COPY-before-install + retry-hardened npm install fixes are
untouched.
Upgrades the Zero Trust surface from five plain tabbed tables into a cloud-console
landing page (the Image #11 operational style), wired to the REAL /v1/zt/* mesh:
- Overview: KPI cards (routers/services/identities/sessions/policies — real
counts or honest em-dash), a router→service→identity topology strip (only
nodes that came back; status-coloured), and a post-quantum POSTURE card tying
the data plane together: Object Storage (S3) ⇄ Zero Trust over Hanzo zap
(ML-KEM-768 key exchange · ML-DSA-65 signatures). PQ-session % shows only when
the backend reports a real cipher — never guessed.
- Honest by construction: each of the 5 sections loads independently; when the
zt backend isn't mounted on this host every section degrades to ONE
BackendStateCard while the (true) PQ posture still shows. No fabricated rows,
trends, or telemetry.
- DRY: the five detail tabs reuse the SAME zeroTrustSurfaces + ForwardSurface
(now exported from ConsoleFeatureModule) — one zero-trust surface, not two.
ZeroTrustModule moved out of the generic table shell into its own rich module
(mirrors Embeddings/Overview); registry repointed.
- Pure logic in zt/logic.ts (counts, topology, posture) with vitest coverage.
tsc --noEmit (strict) clean. v0.7.26.
ROOT CAUSE (proven in the build log): the install's own 'test -f next/dist/bin/next'
PASSED, then 'COPY . .' ran, then the build RUN couldn't find next. Under Kaniko
--single-snapshot, a COPY that follows RUN npm install in the same stage drops the
RUN's freshly-created node_modules. The old multi-stage Dockerfile avoided this (no
COPY after install); my single-stage reorder reintroduced it. Fix: COPY all source
FIRST, then install, then build — node_modules is created by the last RUNs so nothing
clobbers it. This (not the base registry or the .bin symlink) was the real blocker.
The full @hanzo/gui dep tree intermittently installs ~80 packages short (incl next)
— npm reports success but skips them, surfacing later as 'next not found'. Now:
retry-hardened fetch, an explicit 'npm install next' repair if its bin is missing,
and a hard 'test -f' assert so a partial install fails loudly at the install step
(with a clear cause) rather than at build.
Two fixes for the broken builds:
1. Base image: use ghcr.io/hanzoai/nodejs:24-alpine (our mirror of node:24-alpine)
per the ghcr-only policy. (public.ecr.aws rate-limited; mirror.gcr.io was a
stopgap. hanzoai/node is the blockchain node — nodejs is the Node.js runtime.)
Bumped 22→24.
2. Invoke next via node_modules/next/dist/bin/next, not the .bin/next symlink: the
@hanzo/gui RN dep tree intermittently drops the symlink under the build npm
('sh: next: not found') even though the next package installs. Direct invocation
is symlink-independent. Build + runner CMD both updated.
public.ecr.aws/docker/library/node:22-alpine started returning TOOMANYREQUESTS on
the base-image pull (the real cause of the build failures — a partial pull also
surfaced as the misleading 'next: not found'). mirror.gcr.io/library/node:22-alpine
is Google's Docker Hub mirror — reliable, not rate-limited. Combined with the
single-stage install+build, the v0.7.27 admin-fix build is unblocked.
The deps→build cross-stage COPY of node_modules intermittently dropped
node_modules/.bin/next under Kaniko (sh: next: not found at 'npm run build',
despite a clean install adding ~850 packages). Reproduced: local install is fine,
so it's the cross-stage symlink handoff. Install + next build now run in ONE stage
on the same filesystem — the seam is gone. Runner stage unchanged (runtime next
start worked through its COPY all along). Unblocks the v0.7.27 admin-fix build.
IAM never populates isGlobalAdmin AND admin-org members carry isAdmin=false, so the
gate's '&& isAdmin' made NO ONE a global admin → every legitimate admin was bounced
from admin.hanzo.ai to the console (redirect loop). Fix: membership in the reserved
'admin' org alone = global admin. Client OrgGate + server identity.ts. Tenants
(owner!=='admin') stay non-global → admin remains locked to them. v0.7.26
Resolve the home-lead fork to the full OverviewModule (Image-#3 vision: metric
cards + sparklines + tokens/spend/by-model charts + activity + quick actions +
wallet + system status) over the narrower AiMetricsModule lead. AiMetrics stays
its own route. One home dashboard, one way.
Functions' add/add ui/Charts.tsx was dead code (no consumer imported CHART_COLORS/
STATUS_COLORS). Take the canonical superset (Sparkline/LineChart/BarChart/Donut/
BarRows). One chart module, one palette name, no backwards-compat alias. Fixed the
legend swatch to the file's own styled-div idiom (was a themed bg with a hex).
The Overview was a product grid; now it leads with the same tested AI Metrics
dashboard (requests/tokens/spend over time + per-model breakdown, real per-org
data) — the Langfuse-style project home — with the product catalog below for
navigation. DRY: reuses AiMetricsModule exactly. v0.7.25
Merge Overview + Embeddings; collapse the two duplicate chart implementations
(components/charts/Charts.tsx + components/ui/Charts.tsx) into ONE canonical
ui/Charts.tsx superset built on pure @hanzo/gui primitives (promotable to the
shared @hanzo/gui package as a charts category). Repoint OverviewModule +
embeddings/OverviewView. tsc clean.
Compute → Machines as one node of a real DOKS cluster pool (GET /v1/org/{org}/
cluster → machinesFromClusters: sum(pool.count) rows, vCPU/RAM from the DO slug,
monthly cost from the platform bill-from table labeled 'est.'). 8 tabs, 7 metric
cards, status donut, machine table, selected-machine right rail. Per-node CPU%/
MEM%/GPU/uptime/IP the control plane doesn't expose → '—' (never fabricated).
Reboot/Terminate honest-disabled (no real endpoint at that altitude). currentOrg-
scoped; not-configured (501, PaaS token disabled) is the honest first state.
Additive platform.ts only. tsc clean · 20 machines tests green.
The /v1/billing/usage ledger carries per-request rows (metadata.model, totalTokens,
amount in CENTS). The Cost page flattened it with a generic reader → every row
'Usage', no tokens, and cost ×100 ($1.06 shown as $106). Now: looksLikeLedger()
+ perModel() roll up by real model name with correct cents — shared (DRY) with the
new AI Metrics module (StatTiles: requests/tokens/spend/balance, usage-over-time,
per-model breakdown, recent activity) reading the same real per-org data. o11y
RuntimeNotice points at AI Metrics (which has data) when traces are uninitialized.
32 unit tests (billing/aimetrics/format), tsc+build green.
Ships the merged work (multi-model compare Playground with abort-safe billing,
the unified EmptyState/Cost sweep) as a public semver. Overview/Embeddings/
Machines/GPUs/Functions land in v0.7.24 as the conflict integration completes.
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.
P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
`display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
the server and the client's first paint emit identical markup, so there is no
hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
the topbar condenses — org/scope/user/sign-out fold into one right-side menu
so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.
P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
drops the page header + fixed min-height so it fills the sheet; "History"
deep-links to the full /chat page.
P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
+ display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
balance, usage, invoices). "Top up" still deep-links to the brand billing
portal (billing.hanzo.ai) — payment is never rebuilt.
Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
sheet, account menu, wallet identity — all PASS, and NO React hydration
mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
Upgrades the thin StoresModule in place into the Embeddings product (Overview·
Explore·Collections·Jobs·Models·Settings): collections=per-org vector stores
(get-stores), Explore=POST /v1/search on {owner}-{store}-docs (server-resolved
owner — store is only a query param), Models+generate=/v1/models + /v1/embeddings
via the keyless /ai proxy. currentOrg-scoped throughout; no secret reaches the
browser; /ai allow-list NOT widened. Honest-empty everywhere a field/endpoint is
absent (get-cloud-usages not merged yet → metric cards degrade to —; RRF drops
score → —; no vector point-lookup → empty) — never fabricated. Deletes the dead
StoresModule/StoreListView (registry was the only consumer); reuses StoreEditView.
Drive-by: corrects the stale 'built-in' admin-policy assertion to false (gate code
untouched). tsc clean · vitest 67/67 · next build 14/14.
* feat(console): side-by-side multi-model compare Playground
Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.
- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
/v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.
* fix(console): cancel upstream on abort + release reader + honest stopped state
Red review follow-ups on the compare Playground (no scope creep):
1. [MED] Client abort now cancels upstream generation (stops over-billing the
user's own org + leaking N sockets after Stop/tab-switch/unmount):
- app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
a browser->proxy abort propagates proxy->gateway.
- ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
cancel() aborts every column's AbortController.
2. [MED] runner.ts: wrap the SSE read loop in try/finally with
`reader.cancel().catch(()=>{})` so the ReadableStream + connection are
released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
abort. New test asserts the reader is cancelled on a mid-stream error chunk.
3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
RunPhase, CompareColumn shows a Stopped state, History uses
ok = !error && !aborted and shows a 'stopped' badge.
tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Red review follow-ups on the compare Playground (no scope creep):
1. [MED] Client abort now cancels upstream generation (stops over-billing the
user's own org + leaking N sockets after Stop/tab-switch/unmount):
- app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
a browser->proxy abort propagates proxy->gateway.
- ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
cancel() aborts every column's AbortController.
2. [MED] runner.ts: wrap the SSE read loop in try/finally with
`reader.cancel().catch(()=>{})` so the ReadableStream + connection are
released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
abort. New test asserts the reader is cancelled on a mid-stream error chunk.
3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
RunPhase, CompareColumn shows a Stopped state, History uses
ok = !error && !aborted and shows a 'stopped' badge.
tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.
P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
`display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
the server and the client's first paint emit identical markup, so there is no
hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
the topbar condenses — org/scope/user/sign-out fold into one right-side menu
so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.
P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
drops the page header + fixed min-height so it fills the sheet; "History"
deep-links to the full /chat page.
P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
+ display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
balance, usage, invoices). "Top up" still deep-links to the brand billing
portal (billing.hanzo.ai) — payment is never rebuilt.
Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
sheet, account menu, wallet identity — all PASS, and NO React hydration
mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
Upgrades the Fine-tuning module from a read-only job list into a full training
surface wired to the cloud broker (hanzoai/ai /v1/finetune/*) via a same-origin,
user-scoped proxy.
- app/training/[...path]/route.ts same-origin proxy → /v1/finetune/* (cookie
forward, the get-account pattern; allow-listed
sub-paths; org re-resolved server-side). No key
or HF token ever reaches the browser.
- src/lib/api/finetune.ts typed client (unwraps the {status,msg,data}
envelope incl. the HTTP-200 error shape)
- finetuning/HfPicker.tsx browse/search HuggingFace models + datasets;
private/gated flagged; pick → start a job
- finetuning/NewJobPanel.tsx base-model + dataset pickers, LoRA/QLoRA/full,
Recommended preset that just works, GPU with a
live time/cost estimate, Start + Save-as-config
- finetuning/JobsView.tsx jobs list (honest empty/error states)
- finetuning/JobDetail.tsx live status polling, checkpoints, GPU-hours,
Deploy-to-inference, Cancel
- finetuning/logic.ts(+test) pure formatters + cost mirror + config store (17
vitest cases)
- FinetuningModule.tsx tabbed shell (Jobs/New/Models/Datasets/Configs);
internal nav, no new registry routes
tsc --noEmit clean · 17 finetuning vitest pass · next build clean.
Build the Overview page wired end-to-end to live data (no mock data):
- metrics / charts / spend-by-model / recent activity ← /v1/get-cloud-usages
(the hanzo.cloud_usage ledger), org-scoped by the X-Org-Id header;
- wallet balance ← commerce /v1/billing/balance (WalletApi.cloudBalance);
- quick actions ← the real product registry (no dead links).
- src/lib/api/usage.ts: typed UsageApi.overview client (+ allOrgs god-view).
- src/components/charts/Charts.tsx: dependency-free SVG charts (sparkline, line,
bar, donut) — console2 ships no chart lib; these theme to the dark shell and
render in the Next web DOM alongside @hanzo/gui.
- src/components/products/OverviewModule.tsx: the dashboard — time range
(24H/7D/30D/Custom), 4 metric cards w/ sparkline + "vs prior" delta, tokens
line, spend bar, spend-by-model donut + ranked list, recent activity (filter
tabs + pagination), quick actions, honest system-status footer, wallet.
Uniform loading/empty/error states; honest "—" for data with no live source
(GPU/latency → linked to the Status page rather than faked).
- app/(dashboard)/page.tsx: the home renders the Overview (the Explore-products
catalog is kept below).
- src/lib/products/registry.tsx: 'overview' module at /overview — also the
all-orgs admin surface via <OverviewDashboard allOrgs/>.
Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.
- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
/v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.
Click a provider card → ProviderDetailPanel: provider name + stats (models, max
context, from-price, available) + the provider's full model list (name, type,
context, in/out price, status). Pairs with the model click-through. v0.7.21
* fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval
The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.
Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).
RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.
* console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)
The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.
---------
Co-authored-by: Hanzo AI <ai@hanzo.ai>
Co-authored-by: Hanzo <z@hanzo.ai>
The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.
Open tabs reference the prior build's hashed chunks after a deploy; those URLs
fall through to the app shell (HTML), so the browser throws ChunkLoadError /
'Unexpected token <' and blanks. ChunkGuard catches that exact failure and does
ONE guarded full reload to pull fresh HTML + current chunks. v0.7.19
- aicatalog: ONE source (/v1/pricing/models via /ai proxy) — 444 real models,
15 real providers, with context/pricing/specs/tier/provider. No fabrication.
- Model Catalog: polished table (model+params, type, context, in/out $/Mtok,
TRUE provider, live Available status) + stats bar. Fixes the qwen/glm 'Hanzo
(Zen)' mislabel — real provider per model.
- Providers: Explore grid (action cards + real provider cards + stats), was
'0 built-in'. /providers/manage keeps the custom-provider CRUD.
- Brand: our models show as 'Zen', never 'Hanzo (Zen)'. v0.7.18
Pinned wallet widget on every page — per-tenant balance via the /billing proxy +
Top-up deep-links to billing.hanzo.ai/topup (pay.hanzo.ai Square/crypto checkout),
never rebuilds payment. Collapsed mode = wallet icon. v0.7.17.
TODO: promote to @hanzo/ui as a reusable cross-app component (one way, composable).
The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.
Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).
RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.
SECURITY: an org owner (Dave/maxpower, org-level isAdmin) could reach the admin UI.
- gateAllows (server authority for /admin/* proxies): require isGlobalAdmin, NOT
isAdminGranted (which accepted org-level isAdmin). Verified brand-email stays as
2nd factor. Org admins now 403 on admin ops even with @adminDomain email.
- OrgGate: redirect non-global-admins OFF admin.hanzo.ai to the console host +
render-guard so the admin console never flashes. Banner already isGlobalAdmin.
- isGlobalAdmin now recognizes BOTH metadata orgs (admin + built-in), matching
admin-policy ORG_METADATA_OWNERS.
Bundles v0.7.13 (models owner/name token fix + banner gate). v0.7.14.
1. identity.ts: user.id must be <owner>/<name> (IAM GetOwnerAndNameFromId), not the
bare casdoor UUID — fixes Model Catalog 'Could not authorize: wrong token count
for ID <uuid>' for org-member accounts (Dave/maxpower) that carry an id field.
2. OrgGate.tsx: the admin.hanzo.ai ops banner gated on isAdmin (org-level) so an
ORG owner (Dave/maxpower) saw it. Gate on isGlobalAdmin (admin/built-in org) —
org admins are not cross-tenant admins. v0.7.13.
The one remaining cross-service gap (v0.7.x already proxies AI/IAM/KMS). wallet
still hit cookie-only /v1/billing/balance -> 404. New app/billing/[...path] proxy
forwards /billing/* -> commerce with the service token + server-resolved own-org
scope (X-Hanzo-Org + BillingSubject; client cannot widen). Matches the /admin/*
proxy pattern + resolveUser. Wallet.cloudBalance repointed same-origin. v0.7.12.
gui.d.ts was augmenting @hanzogui/web which is not a direct dep (resolves only
via pnpm .pnpm path). Add @hanzogui/core augmentation (which is a direct dep) so
GuiCustomConfig → Conf flows through and shorthand props (bg/px/py/items/justify
etc.) are typed correctly. Also fix Button `color=` → `theme=` in OrgGate banner.
Before: 371 type errors. After: 0.
List both get-global-providers (Hanzo platform-keyed, read-only, Built-in badge)
and per-org custom providers in one view. Users see all providers enabled out of
the box via Hanzo's DO-AI keys; adding a custom provider overrides/extends per org.
- OrgGate: replace hard hard-block for hanzo-org users with a dismissible
amber banner pointing at admin.hanzo.ai; staff can use console.hanzo.ai
normally for cloud work (models, API keys, AI inference etc.)
- OrgGate: restore last selected org from localStorage on sign-in so the
scope remembers where the user left off
- Add e2e/ Playwright tests: sign-in, admin banner, API key create/confirm,
/v1/models verification, OpenAI + Anthropic inference tests
- Add playwright.config.ts targeting https://console.hanzo.ai by default
- package.json: add e2e + e2e:headed scripts
Port the remaining old-console surfaces into console2 as forward-compatible
modules: Zero Trust, Integrations, Referrals, Experiments, Dashboards,
Score Analytics, plus prompt-create/metrics and dataset items/runs sub-modules.
Each surface points at its real/planned /v1 endpoint and renders real rows when
present; a 404/405/503 collapses to the shared honest BackendState card (no demo
rows). console2 now carries the old console's full information architecture while
backend routes light up independently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds were red: docker/setup-buildx-action + docker/build-push-action
spin up an ephemeral buildkit container that can't see the host image
cache, so it re-pulled node:22-alpine from Docker Hub every run and hit
the unauthenticated 429 pull-rate limit; the action's build-summary
artifact upload also hit the Actions storage quota.
Switch to the canonical hanzoai/ci pattern: raw `docker buildx build
--push` on the host builder, which reuses the cached base layer (no
re-pull, no artifact upload). Adds a cache-aware base-image guard that
retries a cold pull with linear backoff. SEMVER tag + GHCR push unchanged.
The /paas proxy forwarded only the god-mode service token, so PaaS
resources couldn't scope per tenant. Now it forwards the full tenant
path the browser stamps:
- X-Org-Id is RE-RESOLVED server-side via the admin policy (orgFor): a
global admin's switched org (the X-Org-Id it already sends) is honored,
a brand admin is pinned to their own — authoritative, never the raw
spoofable claim. Same trust boundary as the IAM/KMS admin proxies.
- X-Project-Id + X-Environment pass through verbatim as sub-scopes within
that org.
Forward-correct: the control plane scopes by tenant once it reads these,
harmless until then. Adds scope.test.ts (9 tests: store round-trip, merge
semantics, persistence, projectEnvironments intrinsic-3 + custom). 38 green.
The staff redirect button passed two children ("Go to admin." + the
{adminDomain} expression); Tamagui Button lays children out with its
icon-gap, so the two text nodes rendered with a stray space
("Go to admin. hanzo.ai"). Collapse to a single template-literal child.
Header retitled "<Brand> staff" → "<Brand> Admin" (white-label via brand).
Projects under the org, each with intrinsic mainnet/testnet/devnet
environments (+ custom), scoping every module at once.
- lib/scope.ts: module-level active { org, project?, environment } the
non-React API client reads synchronously to stamp headers. Org pinned
to the brand org; STOCK_ENVIRONMENTS = mainnet/testnet/devnet.
- lib/api/client.ts: baseHeaders stamps X-Project-Id (when a project is
selected) + X-Environment on every cloud call — one change scopes all
existing modules (o11y, api-keys, deploys, …) with no per-module edits.
- lib/api/projects.ts: ProjectApi over the REAL Hanzo IAM contract
(/v1/iam/get-organization-projects | add-project | delete-project),
keyed (owner, name) with indexed organization = the brand org.
- lib/scope-context.tsx: ScopeProvider — loads the org's projects once,
holds the active selection, mirrors it into the module scope +
localStorage, derives per-project environments. Honest: an unrouted
projects endpoint resolves to org-level only, never a fabricated row.
- components/ScopeSwitcher.tsx: project + environment pickers in the top
bar (next to OrgSwitcher); env dots keyed to network tier.
- components/products/ProjectsModule.tsx: projects CRUD + "Use" to set
active scope; honest not-routed/empty states, no fakes.
- registry: Projects flips external → in-console module (Deploy).
Environments are a console-side scoping dimension (IAM Project has no
environments column); X-Environment is sent canonically for backend
adoption. tsc --noEmit clean.
The 'I don't see any models' wave + sidebar/command UX.
- Models is catalog-first: the default tab is the LIVE model list (~49 Zen
models via the /ai proxy); routing policy moves to a secondary 'Routing' tab
(/models/routing). Retires the duplicate empty-by-default 'Model Catalog' nav
entry — one product, real models on the obvious click.
- Docs are EXTERNAL (brand docsUrl, new tab): a top sidebar 'Docs' entry, the
header '?' icon, and a server /docs -> docs.<brand> redirect (no in-app 404).
ComingSoon's API-docs link repointed off the 404-ing ${cloudUrl}/docs.
- Sidebar collapses/expands from the brand 'H' mark (icon-only <-> full),
persisted to the account; the grid launcher stays. A filter box narrows the
whole sidebar to find any product fast; Overview/Docs are fixed top links.
- cmd+K runs ACTIONS too (toggle theme, browse all apps, open settings, switch
org, ask AI / search docs, sign out, per-org switch), ranked alongside catalog
nav — no dead entries. Removed unused openMode.
- DRY: switchOrg() shared by the switcher + palette; pure match-core (resolveRoute
+ entryMatches) is unit-tested without the GUI tree. Default pins fixed
(dead 'billing' -> 'models','chat'). tsc + 29 vitest + next build clean.
The org-scope module defaults the active org to the brand org (for the cross-org
admin). On a customer host, seed the signed-in user's OWN org as the active scope
the first time (only when no explicit switch is in effect), so the OrgSwitcher
chip and X-Org-Id reflect the real tenant (e.g. maxpower) instead of the brand
org. Never clobbers an admin's deliberate switch.
The @hanzo/gui Input ignores the RN secureTextEntry/keyboardType props on web,
so the password rendered as type=text (visible while typing). Set the web input
type explicitly (type="password") so it masks; also corrects autoComplete to
current-password.
The build step used `node -p require('./package.json').version`; node is not on
the ARC runner PATH, so the substitution silently yielded the tag `:v` and the
operator deploy 404'd. Resolve the version with grep/sed and fail-loud if empty.
Login resolves the user's ORG from their email instead of pinning the brand's
own org: a customer in any org signs into the brand console with email+password.
- iam-login.ts: POST /v1/iam/login with organization: (cross-org email
resolution) → OAuth code → existing completeSignIn → /v1/signin exchange.
Replaces the SDK org-pinned redirect that made non-brand-org users
un-signinable. MFA (NextMfa/RequiredMfa) hands off to IAM's same-site hosted
flow — IAM sets iam_session_id SameSite=Lax, so cross-site fetch MFA can't
complete here; no faked inline step.
- SignInForm: email+password state machine; social (getProviderSigninUrl) kept.
- OrgGate: the console runs in the user's CUSTOMER org — blocks the internal
brand org (owner===config.iamOrgName → admin.<domain>) and the zero-org case.
- SECURITY /paas/[...path]: gate with getAdminGate (403 if not a brand admin) +
runtime nodejs. The forwarded PAAS_SERVICE_TOKEN is control-plane god-mode and
was previously reachable by any authenticated browser.
- getAdminGate: orgScope=user.owner (was brand.id) so the IAM/KMS proxies pin a
non-global admin to their OWN org; require a VERIFIED email (authoritative IAM
recheck for thin claims). admin-policy + tests updated (14 pass).
Root cause of empty models + broken org switcher: /v1/{iam,kms,models} 404/401
(cookie-only) on the console host. Route every privileged call through console2's
own server proxies (user bearer + admin gate), and make org scope a switchable value.
- models-catalog: repoint /v1/models -> /ai proxy (shared aiV1Url in client.ts);
catalog now populates with live Zen models. playground reuses the shared aiBase.
- org-scope.ts: currentOrg/setCurrentOrg/isScopedAway/filterOrgs. client.ts stamps
X-Org-Id: currentOrg() so data modules re-scope on switch.
- OrgSwitcher: lists ALL visible orgs, filter box, in-place re-scope (set+reload).
- IamModule/AuditModule read currentOrg(); KmsModule now a names-only inventory
over KmsAdminApi.list (the /admin/kms proxy), values never fetched.
- admin-policy.ts: pure gateAllows/ownerAllowed/orgFor extracted from the gate +
routes (decomplect) so the shipped predicate is the tested one.
- vitest: 22 tests RED->GREEN (gate allow/deny+scoping, scope+filter, catalog /ai).
tsc --noEmit + next build clean.
Two more old-console pages as real o11y modules: Observations (the flat
cross-trace span/generation view, GET /v1/o11y/observations) and Users (per-user
analytics rollup, GET /v1/o11y/users). O11yApi extended with both endpoints +
the O11yUser type. Same paged DataTable + honest RuntimeNotice pattern as the
other o11y modules; registered under Observe. tsc clean.
Flip every 'soon' product (ComingSoon stub) to a real, enabled in-console module
— attestations/oracles/indexer/tokens/settlement, alerts/logs, pipelines/
releases/builds/environments, hsm/authz/service-mesh/load-balancer/vpc, jobs/
edge/functions/containers/machines/gpus, agents/inference/finetuning. Each
follows the established module pattern: typed restGet over the same-origin /paas
proxy, DataTable, honest interpretPlatformError→PlatformStateCard not-configured
state, Refresh. Registry: status soon→enabled + routes wired. Full tsc clean.
Catalog is now 61 enabled modules + 16 external; no 'soon' stubs remain.
The mark now follows config.brand — each brand's OWN published, interactive
animated SVG (@hanzo/logo, @luxfi/logo, @zooai/logo: getAnimatedSVG, load→hover→
press). Static block-H during SSR/first paint (no hydration mismatch). The logo
is now the brand's playable 'AI' on every loading + sign-in surface.
The mint/revoke success path called useSession().reload(), which flips the
session loading flag -> AuthGate renders its Loader -> the dashboard (and
ApiKeysView) unmounts -> the freshly-minted key (local newKey state) is lost on
remount, so 'Your new API key' never showed. The session masks accessKey anyway,
so the reload provided no benefit. Show the new key from local state only.
Verified live (v0.6.0): /keys POST 200 + /ai chat proxy 200 (real 'PONG'/Hanzo
answer); only the keys UI render was affected.
P0 — make it work for Dave:
- Root cause of 'chats/playground don't work': /v1/chat/completions requires
Authorization: Bearer; the browser sent cookie-only -> rejected. Fixed with a
keyless server-side AI proxy (app/ai/[...path]) that mints a short-lived
user-bound IAM token (issue-user-token, hanzo-console app-on-behalf) and
forwards to the gateway. No key in the browser, no rotation per turn.
- API keys: app/keys route mints/rotates/revokes the per-user hk- key via IAM
(mint-user-keys/revoke-user-keys); ApiKeysModule is now create/copy/rotate/
revoke with show-once secret handling.
- Chat is interactive (ChatConversation) over AiApi.chat; honest 402 billing state.
- src/lib/server/identity.ts: server-only trust boundary (resolveUser + IAM ops).
P1 — chrome polish:
- Header/sidebar show the Hanzo H mark + 'Console' (HanzoMark, BrandLogo with
org-logo fallback).
- Fullscreen Launchpad-style app launcher (AppLauncher) with filter, from the
header 'Apps' button, sidebar grid icon, and the command palette.
- cmd+K palette gains a 'Browse all apps' affordance.
Verified end-to-end (curl, real creds): minted hk- key + issued user JWT both
200 on api.hanzo.ai/v1/chat/completions. typecheck + next build clean.
Async is the canonical home for Tasks per the org taxonomy. Appended as an 11th
category so the ten GCP-equivalent groups keep their exact labels and order
(no reorder); catalogByCategory is data-driven, so it renders generically.
Memory (Data): list + search (kind filter + text) + detail/edit + add/delete,
honest 'initializing' card until /v1/memory deploys. Tasks (Compute, GCP Cloud
Tasks): namespace selector + Workflows/Schedules tabs + cluster strip, workflow
detail + durable history, on the LIVE /v1/tasks engine. Both render honest
states on a gated/absent route; mutations report through the shared toast.
Entries appended (no reorder); grouped by category.
Dashboard layout wraps the shell in ToastProvider + CommandPaletteProvider. The
shell top bar gains the command search box (opens the palette), theme toggle, a
help launcher (opens the palette in docs mode), and the org switcher; a breadcrumb
bar sits below it. ResourceModule create/delete now report through the toast, so
the one feedback primitive is actually used.
CommandPalette is ONE command surface (Cmd/Ctrl+K) with modes selected by the
query: default fuzzy-filters the catalog and jumps to any product; > asks the AI
to find a product (NAV <id>) or answer; ? asks the docs knowledge store (RAG).
searchCatalog is the dependency-free fuzzy ranker. OrgSwitcher shows the account's
org and, only for a real multi-brand membership, switches to that brand's console
host. Honest throughout — AI/RAG/IAM failures degrade to truthful states.
Toast is the one feedback primitive — ToastProvider + useToast() with a portalled
top-right viewport, theme-aware accents, auto-dismiss. ThemeToggle flips dark/light
via next-theme. Breadcrumbs derive Home/Category/Product/detail from the route +
catalog, so a new product gets correct crumbs for free. GUI primitives only.
AiApi composes the single OpenAI-compatible gateway binding (PlaygroundApi):
plain chat, listModels, and ragChat grounded in a knowledge store (docs by
default) via the built-in retrieval path — the X-Retrieval-Store header turns
on RAG and names the store; the org owner is resolved server-side. restPost and
PlaygroundApi.chat gain an optional headers arg so RAG and plain chat share ONE
binding. No parallel AI client; failures throw ApiError for honest states.
billingUrl moves from SHARED to the per-brand BRANDS table, resolved like
iamUrl. Each brand's console (Cost product, PlansModule manage-billing) links
to ITS billing host, scoped to ITS org by the brand JWT. Cloud backend stays
shared/multi-tenant.
Native @hanzo/gui modules over the REAL /v1/o11y REST surface; honest
RuntimeNotice on 503/404 — no fabricated data.
- Traces detail: flat observations table -> nested span TREE waterfall
(SpanTree) built from parentObservationId, with a Tree|Table toggle.
Orphans/cycles surface as roots so every observation appears once.
- Score Configs: new Observe module on /v1/o11y/score-configs (read-only).
- Annotation Queues: new Observe module on /v1/o11y/annotation-queues
(real public REST list — contradicts the earlier 'no backend' finding).
Skipped (honest, no usable Hanzo backend or would duplicate/fake):
dashboards/widgets + score-analytics (no saved-dashboard REST, charts
would be fabricated), llm-connections/mcp (covered by Providers/ModelCatalog),
agents (backend not routed at gateway — real paths 404), org/projects deep
settings (already covered by Settings + IAM, no write endpoints), and the
Langfuse integration surfaces (feature-flags/entitlements/automations/
batch-exports/developer-tools/slack/mixpanel/blobstorage).
catalog 73 -> 75 entries; appended only (no reorder).
Merges port-{settings-models,prompts-evals,o11y-core}. 73 catalog entries across the 10 categories; new modules wired to real /v1 backends with honest states (o11y 503 until runtime init). tsc + next build green.
ProviderListView/ProviderEditView imported ~/lib/zap, but the cloud /zap
WebSocket face is not served (edge returns SPA HTML, not a WS upgrade — per
lib/zap/client.ts LIVE STATUS), so Providers rendered 'Failed to load
providers'. Switch both to ~/lib/api (identical surface). Part of v0.1.8.
Port the old console (Langfuse-fork) eval surfaces into console2 as native
modules on @hanzo/gui + @hanzogui/lucide-icons-2 — no antd/shadcn/tremor.
Wired to the REAL cloud /v1 backend; honest loading/empty/unavailable states
on 404/503 (NEVER fabricated prompts/datasets/scores).
- Playground (AI): GET /v1/models + POST /v1/chat/completions — fully working
model run (system prompt, message thread, sampling params, token usage).
- Evals (Observe): POST /v1/evals/runs (real per-item run summary) +
GET /v1/evals/scores (real scores list), Run/Scores tabs.
- Datasets (Observe): POST /v1/evals/datasets + /v1/evals/dataset-items (real
create); forward-compatible GET list with honest unavailable card.
- Prompts (AI): forward-compatible GET /v1/prompts probe; honest deep-link card
(no /v1 prompts route mounted yet).
Shared: ModelPicker (one way to pick a gateway model), BackendState (honest
/v1 error → card). API: lib/api/{playground,evals}.ts via the REST client.
Registry: playground+evals stubs upgraded in place (no reorder); prompts+
datasets appended. tsc --noEmit + next build both green.
Port the old console's observability core (Langfuse-shaped) to native
@hanzo/gui modules:
- Traces: list + detail (overview, I/O, observations, scores) at /o11y
- Sessions: list + detail (overview + traces) at /sessions
- Scores: list at /scores
All read the REAL /v1/o11y endpoints and render honest states (loading /
runtime-initializing / empty) on 503/404 — never fabricated traces or
charts. Replaces the Traces deep-link placeholder (ObservabilityModule
removed); appends Sessions + Scores entries (no reorder of existing).
tsc --noEmit and next build both pass.
DRY pieces for the o11y surfaces: pure formatters (date/latency/cost/
score value/JSON), shared detail parts (DetailRow/Badge/Tags/JsonCard),
the honest RuntimeNotice (503 not-initialized / 404 unrouted / access /
error), and the list Pager. No fabricated data.
Typed client for the /v1/o11y surface over the plain-REST transport
(restGet/v1Url): list endpoints return { data, meta }, detail endpoints
one object. 503/404 surface as typed ApiError so callers render honest
states. Tenancy is server-side (cookie credentials only).
Port hanzoai/console's deep settings + models pages into console2 as native
modules wired to the REAL /v1 + /v1/iam endpoints. Honest loading/404/401/503
states everywhere — never fabricates keys, models, or settings.
New modules (registry, appended — no reorder):
- Model Catalog (AI): real GET /v1/models + best-effort /v1/pricing/models
overlay; provider filter, premium tier, $/Mtok columns. Read-only catalog
(routing lives in Models, credentials in Providers).
- API Keys (Dev): the account's real cloud credential (accessKey/accessSecret
from get-account), masked by default with explicit reveal + copy; honest
"managed by gateway" state when no key material is exposed to the browser.
- Settings (Security): tabbed General / API Keys / Members / Branding. General
reads real account (get-account) + org (/v1/iam/get-organization); Members
reads /v1/iam/get-users; API Keys embeds the shared ApiKeysView (DRY);
Branding shows the real per-host runtime config. Identity mutations deep-link
to IAM rather than being re-implemented.
Shared + API:
- ui/States.tsx: one honest async-state renderer (honestError + ErrorState +
asApiError) with per-surface copy overrides. AdminModule refactored onto it
(removes its duplicate honestError/ErrorCard).
- api/models-catalog.ts: CloudModelApi over the REST (non-envelope) /v1/models.
- api/admin.ts: IamAdminApi.organization(name) single-org getter.
Shell: top-bar account name now links to /settings (canonical account menu).
Gates: tsc --noEmit exit 0; next build exit 0.
Skipped (no real console2 backend — porting as shells would be slop): Langfuse
feature-flags (2 internal flags), developer-tools (Langfuse-branded copy),
automations/batch-exports/integrations (tRPC+Prisma only). Members/Audit/
Secrets/LLM-connections/Billing already exist as IAM/Audit/KMS/Providers/Cost.
main already supersedes it: same real platform contract (/v1/apps +
/v1/org/{org}/cluster) PLUS X-Org-Id (resource modules), Bot/Wallet honest
states, Clusters real-contract rewrite, and the PaaS token fix. Recording the
integration so the line is single; cutting v0.1.8 as the cumulative release.
Live Playwright verification surfaced real wiring bugs; fixed all in console2
(honest states everywhere, no fakes):
- client.ts: stamp X-Org-Id (brand org) on every cloud call — the provisioning
service 403'd 'X-Org-Id required' on the direct cloud-api path. Fixes the 7
data modules (vector/sql/kv/s3/datastore/docdb/search) → real data / empty.
- platform.ts: rework to the REAL platform contract — GET /v1/apps (apps
inventory) + GET|POST /v1/org/{org}/cluster; drop dead /v1/clusters + k8s
passthrough. Status = real health board; Kubernetes = real workloads per
cluster; Clusters = real dedicated-DOKS list (honest empty).
- platform/state.tsx: upstream 401/403 → honest 'not configured'.
- BotModule: /v1/bot/health 404 → honest 'not routed on this host'.
- WalletModule: /v1/billing/balance 404 → honest 'not available'.
- StatusTag: understand platform health verdicts (green/yellow/red).
typecheck + build clean. Operator CR token repointed (universe) to the correct
paas-console-token (the old hanzo-paas/MASTERTOKEN is rejected by platform).
The PaaS modules targeted an assumed platform surface (/v1/clusters,
/v1/org/{org}/cluster/{id}/k8s/{kind}) that does not exist — the live
platform.hanzo.ai/v1 REST API serves /v1/org/{org}/cluster (org-scoped
dedicated clusters) and /v1/apps (the workload/drift board). Re-point the
single data layer (src/lib/api/platform.ts) at the real endpoints:
- listClusters → /v1/org/{org}/cluster (unwrap {clusters}); org from config.
- add AppsApi.listApps → /v1/apps (unwrap {apps}) — the live deploy data.
- drop the dead k8s-browse client (no backend) + CLUSTER_ROUTES guess.
Status + Kubernetes now render the real /v1/apps workloads (cluster,
namespace, image, health) instead of gating on a non-existent k8s-browse
behind a (for hanzo) empty dedicated-cluster list. StatusTag tones the
drift-board health (healthy/warning/down). Auth is unchanged: the /paas
proxy already sends Authorization: Bearer; the fix is the token VALUE
(operator CR → paas-console-token) + these paths.
Rebuild the product catalog to the canonical ten categories, in order:
AI · Compute · Data · Network · Security · Dev · Deploy · Observe · Web3 · Apps.
66 entries; each names its Google Cloud equivalent. Honest 3-state enablement:
enabled (23, in-console modules) / external (16, live Hanzo surfaces) / soon (27).
Every real working module is preserved (recategorized, not broken).
Add Status (Observe): live health of every Hanzo service across clusters, from
REAL data only — composes PlatformApi.listClusters + KubernetesApi deployments
over the /paas control plane, with honest not-configured/unavailable/empty
states and no fabricated dots.
Decomplect: one coming-soon surface for all soon leaves; delete the dead
duplicate PaaS client (PlatformModule + lib/paas) and the unused coming-soon
factory. Release v0.1.6.
Phase 5 — add the Observability category with truthful entries, no fake charts:
- Observability (/o11y): console-native module that probes the REAL /v1/o11y
runtime and reports status (online / not-initialized 503 / not-routed 404 /
access / error), deep-links to the full observability surface for traces,
evals, and prompts, and marks the native in-console browser as coming
(HIP-0106). Never renders placeholder telemetry.
- Insights (external) + Analytics (relocated here) round out the category.
This is the staged first step for the largest old-console surface; the deeper
native port is honestly deferred, not faked.
Phase 4 — one platform transport: route ALL control-plane calls through the
same-origin /paas proxy (server-side token injection, no CORS, honest 501
when unset) instead of direct cross-origin platform.hanzo.ai.
- platform.ts: clusters now go via /paas; add KubernetesApi over
/v1/org/{org}/cluster/{id}/k8s/{deployments,pods,services,ingresses,events,crs}.
- KubernetesModule: cluster picker + resource tabs, defensive per-kind columns,
honest loading/not-configured/backend-unavailable/error/empty states.
- ClustersModule: flip 'soon' -> real; render the shared honest state card for
not-configured (501) / backend-unavailable (404).
- New shared platform/state.tsx interprets /paas errors one way.
Phase 3 — wire the console to the existing identity/secrets subsystems over
the canonical /v1 surface (HIP-0111), each an honest module:
- Identity (/iam): tabbed Organizations / Users / Roles (RBAC) over Hanzo
IAM /v1/iam/get-{organizations,users,roles} (casdoor envelope). One generic
AdminListView drives all three; 404/401/empty are explicit honest states.
- Audit (/audit): identity & access event log over /v1/iam/get-records.
- Secrets (/kms): KMS is zero-knowledge (encrypted names+values, token/ZAP
auth, no list-values endpoint) — the module states the model, probes the
real /v1/kms surface to report reachability, and deep-links to the KMS
console. It never fabricates a secret table.
iam/kms flip from external links to modules; ext.iam/ext.kms removed.
Replace the ad-hoc AI/Data/Apps/Identity/Infrastructure/Commerce grouping
with the exact nine categories + order from the marketing site product
dropdown (navigation-data.ts productsNav): AI & Agents, Developer, Apps,
Compute, Data, Async, Platform, Observability, Web3. One taxonomy across
every Hanzo surface. Empty groups don't render (catalogByCategory skips them).
- WalletModule (Commerce): connect a non-custodial wallet on Hanzo Mainnet
(36900) via ethers/EIP-1193, show wallet HUSD + cloud credit balances, and
top up credit with HUSD. Honest states throughout (no wallet, HUSD greenfield,
chain unreachable, endpoint unconfigured) — never a fabricated balance.
- src/lib/wallet/hanzo-evm.ts: one canonical Hanzo Mainnet + HUSD definition
(ethers v6), env-overridable RPC; HUSD address is public (NEXT_PUBLIC), never
a secret.
- src/lib/api/wallet.ts: cloud balance via the real GET /v1/billing/balance, and
recordWalletTopup → the console's own POST /billing/topup/wallet.
- app/billing/topup/wallet/route.ts: server route (mirrors /paas) — verifies the
HUSD transfer on-chain, then records to commerce as a husd crypto payment and
credits the balance. Hosted here because billing.hanzo.ai is a static export
and commerce is owned elsewhere. Server-only config (KMS, never NEXT_PUBLIC).
- registry: wallet entry; api/index: WalletApi export. Adds ethers 6.17.0.
'Interstitial screens to discover guides/docs/how-tos + link to GitHub OSS for
each product' + the OSS-gets-paid hook:
- /discover/<id> route + ProductInterstitial: identity + open/get-started CTA,
Docs & guides + Open-source (GitHub) link cards, and an 'Open source gets paid'
card stating the model (25% of cloud revenue -> OSS contributors, computed from
each deployment's SBOM, settled on-chain in HUSD) with Contribute + OSS-dividends
links. Catalog cards gain a Learn-more (info) affordance; non-enabled Get-started
routes to the interstitial.
- OSS_PROGRAM constant (one source of truth: 25% / HUSD / SBOM / dividends dashboard),
aligned with hanzo.ai sbomRevenueConfig + the commerce contributor/payout system.
tsc clean; next build green (/discover/[id] live).
Per directive 'use proper semver for all, no sha': build-image.yml now tags
ghcr.io/hanzoai/console2 with the semver version only — a v* git tag publishes
that exact version, a main push publishes v<package.json version> (bump to
release). Drops the sha-<sha7> and floating :latest tags. Bump 0.1.1 -> 0.1.2
(GCP-grade resource detail + Plans & Pricing + Team launcher).
The 'manage/create databases ala GCP' + 'discover, enable, pay' surface,
all on REAL backend contracts (no invented controls):
- ResourceModule gains an instance DETAIL view (list+detail in one component,
mirroring ProvidersModule's params.name): click a resource -> GET /v1/<kind>/<name>
overview (status/kind/endpoint/user/db/created) + connection guidance + danger-zone
delete. New resourceRoutes() helper binds index + :name to one instance (DRY);
the 7 data products (sql/vector/datastore/kv/search/s3/docdb) use it. Resources
are serverless/create-by-name (POST /v1/<kind> takes only {name}) — no fake
tier/size/region knobs.
- PlansModule: GCP-style Plans & Pricing on the live rate card (GET /v1/pricing):
per-tier cards (vCPU/RAM/SSD/transfer, monthly+hourly, feature bullets, free/popular
badges) + block-storage metering. Paying delegates to config.billingUrl (the one
money surface, never reimplemented). New typed PlansApi (plans.ts).
- Team launcher: hanzo.team catalog entry (Apps, external team.hanzo.ai).
tsc --noEmit clean; next build green (9/9).
Flip the existing 'bot' catalog entry from an external link to an in-console
module. New BotModule reports live gateway status from /v1/bot/health (via a
small BotApi on the REST layer, since the bot speaks plain JSON, not the
casibase envelope) and deep-links the operator surfaces (bot home, control UI,
docs, source). Backend (hanzoai/bot -> bot-gateway) is already routed at
/v1/bot/* by the unified gateway.
Found via testing: cloud.hanzo.ai serves the SPA catch-all (200 text/html for any /v1 path), NOT the backend; the real /v1 is behind the unified gateway api.hanzo.ai (hanzoai/ingress→hanzoai/gateway v2.13.0 → cloud / separate services / per-org k8s, rate-limited+gated+priced). Browser uses same-origin /v1 (each console host's ingress proxies to the gateway); this SSR fallback now points at the gateway.
Resolves the brand at RUNTIME from the request hostname (console.hanzo.ai→hanzo, console.lux.cloud→lux, console.zoo.cloud→zoo). Tenancy: ONE cloud /v1 backend serves all orgs (same-origin per host → first-party cookie, no CORS), each brand authenticates against its OWN live IAM (hanzo.id / lux.id / zoolabs.id / pars.id; client_id <org>-cloud per HIP-0111). config is a brand-aware Proxy so the /v1 client + IAM SDK go per-host with no consumer changes; cloudUrl is same-origin (window.origin); NEXT_PUBLIC_* still override. Dockerfile + CI no longer bake NEXT_PUBLIC_* (that pinned one brand) → one brand-agnostic image (sha-<sha7>+latest). Fixed a 'Hanzo' subtitle brand-leak. Verified: tsc clean + next build green. NOTE: cloud backend must accept all brand issuers/auds; each console host's ingress must proxy /v1 to the backend + register the redirect URI in that brand's IAM app.
The mainnet branch wrote a two-line tags value with $'\n', which GitHub
Actions rejects for key=value outputs ("Invalid format ...:latest") —
so every Build Docker Image run failed at 'Compute per-env config' and
no image was ever produced by CI. Use the multi-line heredoc output form
so build-push-action receives both newline-separated tags.
NEXT_PUBLIC_IAM_URL is inlined at build time, so the deployed image sent
the browser authorize redirect to https://iam.hanzo.ai — which mints
iss=https://iam.hanzo.ai, a different issuer than the canonical
https://hanzo.id that the cloud /v1 backend validates. Sign-in dropped
on iam.hanzo.ai and never round-tripped back to console2.
- src/config, .env.example, Dockerfile ARG, mainnet CI build-arg: point
the browser at https://hanzo.id (the value baked into the image).
- Align iamAppName/iamClientId source defaults to hanzo-cloud (was the
stale hanzo-console / empty), matching the cloud-api backend binding
(iamApplication / IAM_AUDIENCE = hanzo-cloud). A default build now
targets the correct app instead of a non-existent one with no client.
App/client stay hanzo-cloud on purpose: console2 is a front-end of the
shared cloud /v1 backend, which exchanges the code and validates aud as
hanzo-cloud — it is not its own IAM principal.
Job 2: reorganize the catalog into the canonical 10-category CLOUD AXIS
(AI/Compute/Data/Network/Security/Dev/Deploy/Observe/Chain/Apps) so console2
reads like a cloud console. Three entry kinds (module/external/soon) => zero
dead links, zero fakes; 'soon' primitives render an honest ComingSoon overview.
Job 3: PaaS embedded natively under Deploy (PlatformModule) wired to the real
platform.hanzo.ai control plane via a same-origin /paas proxy (service token
server-side from KMS). Real apps + declared/running/drift + redeploy; honest
loading/not-configured/empty states.
Job 4: catalog honest by construction; PaaS shows only real data. No placeholder
cards, demo projects, or lorem stats.
Union merge — feature's categorized catalog architecture is canonical, with
main's data/storage products + status badges folded in (lose nothing):
- registry.tsx: feature's CatalogEntry discriminated union (category + kind +
admin + derived productModules) as the base; FOLD IN main's data/storage cloud
products (vector, sql, datastore, kv, search, s3, docdb, base, clusters) as
categorized 'module' entries via resourceModule/comingSoon — these are the
console frontend for cloud's /v1 provisioning control plane. Extend
ProductStatus to 'enabled'|'available'|'soon'|'waitlist' and add repo? so the
folded products keep their status badges. Resolve the id 'search' collision:
managed Search data product keeps id 'search' (owns the route, maps to the
provisioning kind); feature's external search.hanzo.ai becomes 'ai-search'.
- DashboardShell.tsx: feature's Pinned + categorized NavRow shell; FOLD main's
SOON/WAITLIST status badge into NavRow (feature had dropped it).
- page.tsx: feature's grouped ProductCard grid; extend StatusBadge to render
Soon/Waitlist (blue/yellow) alongside Enabled/Available (folds main's badge).
Verify: tsc --noEmit strict CLEAN; next build green (6/6 pages). The repo's
untracked PaaS WIP (ComingSoon/PlatformModule/paas — another agent's, in neither
branch) is left untouched and excluded from this commit.
* feat(products): register full data/storage catalog + enablement status
Adds `status` ('enabled'|'soon'|'waitlist') + `repo` to every ProductModule so
the console tracks enablement of all Hanzo cloud products in ONE place. Registers
the OSS-Google-Cloud data/storage suite as modules — Vector, SQL, Datastore, KV,
Search, S3, Base (soon) and DocDB (waitlist) — each a ZAP-native Hanzo fork mapped
to its repo, with a ComingSoon placeholder (repo link + status) until its admin
module lands. Nav + dashboard cards render the status badge.
Products → repos: Vector=hanzoai/vector (Qdrant-compat), SQL=hanzoai/sql,
Datastore=hanzoai/datastore (ClickHouse-compat), KV=hanzoai/kv (Redis-compat),
Search=hanzoai/search, S3=hanzoai/s3, Base=hanzoai/base, DocDB=hanzoai/docdb.
* feat(products): add Clusters module (shared Hanzo Cloud vs BYO DOKS)
The data/storage products (Vector/SQL/Datastore/KV/Search/S3/Base/DocDB) are
all live on hanzo-k8s via the operator. Adds the one new control-plane surface
the platform needs: Clusters — choose where workloads run (shared multi-tenant
Hanzo Cloud, or your own/Hanzo-provisioned DOKS cluster, reconciled by the same
operator). Deploy-any-repo stays under Applications.
* feat(products): working data/storage + clusters admin modules
Replace the comingSoon() placeholders for sql/vector/datastore/kv/search/s3/
docdb with a DRY resourceModule() factory over the provisioning REST contract
(POST/GET/DELETE /v1/<kind>): list, create-with-once-shown connectionString +
password reveal ("store this now"), and per-row delete. Tenancy is server-side
(gateway injects X-Org-Id), so the browser sends cookie creds only.
- lib/api/client.ts: plain-REST helpers (restGet/restPost/restDelete + v1Url)
beside the casibase envelope path — provisioning + platform speak raw JSON /
201 / 204 / DELETE, reusing the same cookie creds + ApiError. One transport.
- lib/api/provisioning.ts: ProvisioningApi keyed by ResourceKind.
- lib/api/platform.ts: PlatformApi (DOKS clusters) with centralized
CLUSTER_ROUTES + DOKS region/size options; base = NEXT_PUBLIC_PLATFORM_URL.
- components/products/ResourceModule.tsx: the factory (list/create/delete,
copyable masked secret reveal, slug validation, loading/empty/error).
- components/products/ClustersModule.tsx: list + provision DOKS + attach (name
+ kubeconfig) against the platform control plane.
- components/ui/StatusTag.tsx, lib/slug.ts: shared/DRY across both modules.
- registry: swap routes to the working modules and clear 'soon'/'waitlist'
status so nav badges clear. Base stays a placeholder (no single-resource).
- fix two pre-existing type errors in ComingSoonModule (maxWidth->maxW,
theme active->blue) so the branch typechecks clean.
kind map: sql->databases, vector->vector, datastore->datastore, kv->kv,
search->search, s3->storage, docdb->docdb.
Verified: npm ci + tsc --noEmit => 0 errors (CI-equivalent flat install).
* console: native product naming + gate Clusters to coming-soon
Native naming (Hanzo brand rule — product name only, no upstream OSS
name in any surface):
- ResourceKind wire kinds align to the cloud /v1 contract exactly:
databases→sql, storage→s3 (vector/datastore/kv/search/docdb unchanged).
- Strip every upstream name from product descriptions + connectionHints
(Postgres/Qdrant/ClickHouse/Redis/Meilisearch/Mongo/-compatible → the
Hanzo product name). Zero forbidden names remain in registry.tsx.
Clusters: register as status:'soon' rendering the coming-soon placeholder
(repo hanzoai/operator). The platform attach/provision endpoints in
platform.ts are unconfirmed (PR not merged), so we don't ship a button to
a non-existent endpoint. ClustersModule.tsx + platform.ts stay in the tree
(unreferenced from the live route) to flip back to enabled in one line once
the platform surface lands. Data products (sql/vector/datastore/kv/search/
s3/docdb) stay ENABLED — their /v1 kinds are real and shipping now.
tsc --noEmit: clean.
---------
Co-authored-by: zeekay <z@zeekay.io>
Turn console2 into the one place to see, enable, and manage every Hanzo
product, with billing → billing.hanzo.ai for all.
- registry: ONE product catalog (categories + module-vs-external + enablement
status), the single source of truth for nav, overview, and router. Adding a
product = one CatalogEntry.
- favorites: pin products to the sidebar. Built on a new account-backed
preferences layer (usePreferences) — customizations persist to the IAM user
account, so they follow the user across every device/login and product.
localStorage is only a fast-paint cache.
- shell: Pinned section + categorized catalog; exact-match active (no
double-highlight); each row opens (in-console route or external tab) + pin toggle.
- overview: product catalog grouped by category with status, pin, and
Open/Get-started.
- api: AccountApi.updatePreferences → POST /v1/update-preferences (self-scoped).
Typecheck + next build clean.
npm ci failed in CI (node:22-alpine npm 10.9) with EUSAGE 'Missing:
react-native-worklets@0.8.3 from lock file' — @hanzo/gui's react-native
optional/platform deps resolve differently across npm versions, so a
lockfile built by one npm is rejected by another's strict ci. npm install
reconciles deterministically for the build platform.
The ZAP-native data layer (src/lib/zap/{client,transport,providers}.ts)
imports @zap-proto/web/client, @zap-proto/zap, and superjson; declare them
so a clean `npm ci` in CI resolves them (the prior build failed: 'Module
not found: @zap-proto/web/client' because the lockfile lacked them).
Swap the Providers views from the REST `~/lib/api` to the ZAP-native
`~/lib/zap` (one import line each) — proving the @hanzo/gui + @zap-proto/web
go-forward: same call surface, binary ZAP over WebSocket instead of
JSON-over-HTTP, zero view-component changes.
- src/lib/zap/index.ts: barrel re-exporting ProviderApi (= ProviderApiZap),
ApiError, Provider — the drop-in twin of ~/lib/api.
- ProviderListView/ProviderEditView: import from ~/lib/zap.
- providers.ts list(): return { rows, total } to match the REST getList
contract exactly (true drop-in; fixes the list-view shape).
tsc --noEmit (strict) clean. Backs onto cloud's new /zap WS face which
dispatches each call into the same /v1 casibase handlers.
Billing is owned by hanzoai/commerce (backend) + hanzoai/billing (portal at
billing.hanzo.ai) — the console must not reimplement payments/balance. Add an
externalLinks registry (orthogonal to product modules) and render it in the
shell; Billing opens config.billingUrl (NEXT_PUBLIC_BILLING_URL, default
https://billing.hanzo.ai) in a new tab.
The backend auto-creates an anonymous-user session for the chat product, so
get-account returns status:ok even with no real sign-in. As an ADMIN console
that made AuthGate show the dashboard for an unauthenticated visitor, while
admin endpoints (get-providers, etc.) rejected the anon session with 'Please
sign in first'. Treat type==='anonymous-user' as null so the console requires
a real IAM sign-in.
Mirror the Providers pattern: product-module + list/edit views on @hanzo/gui +
typed /v1 API modules, registered in the nav. tsc 0 errors, next build green.
Next.js 15 multi-stage build; NEXT_PUBLIC_* baked at build (same-origin /v1,
IAM client_id=hanzo-cloud to match cloud-api's /v1/signin). Self-hosted ARC
runner, push to ghcr.io/hanzoai/console2.
- Install Playwright Chromium for agent browser review: `pnpm run playwright:install`
Minimum verification matrix:
| Change scope | Minimum verification |
| --- | --- |
| `web/**` only | `pnpm --filter web run lint` + targeted web tests |
| `worker/**` only | `pnpm --filter worker run lint` + targeted worker tests |
| `packages/shared/**` (non-schema) | `pnpm --filter @langfuse/shared run lint` + one targeted web check + one targeted worker check |
| `packages/shared/prisma/**` or `packages/shared/clickhouse/**` | `pnpm --filter @langfuse/shared run lint` + `pnpm run db:generate` + targeted web/worker regressions |
| Public API contract (`web/src/pages/api/public/**`, `web/src/features/public-api/types/**`, `fern/apis/**`) | web lint + targeted server API tests + Fern update/regeneration; never hand-edit `generated/**` |
| Cross-package refactor (`web` + `worker` + `shared`) | `pnpm run lint` + `pnpm run typecheck` + targeted tests per impacted package |
## Repo Rules
- Keep changes scoped; avoid unrelated refactors.
- Prefer package-local implementation details in package `AGENTS.md` files.
- Do not hand-edit generated/build artifacts:
-`generated/*`
-`web/.next/*`
-`web/.next-check/*`
-`*/dist/*`
-`packages/shared/prisma/generated/*`
- Public API contract changes must update Fern sources in `fern/apis/**` and
regenerated outputs; never hand-edit `generated/**`.
- Keep tests independent and parallel-safe.
- For bug fixes, write the failing test first, confirm it fails, then fix the
bug.
- For user-visible frontend changes in `web/**`, review the affected flow in a
real browser with the Playwright MCP server before signoff. Use
`skills/frontend-browser-review/SKILL.md` and `../web/AGENTS.md` for the
browser-review loop.
- Never commit secrets or credentials. Keep `.env*.example` files in sync with
required env vars.
## Shared Agent Setup
-`.agents/AGENTS.md` is the canonical root guide.
- Root `AGENTS.md` is a symlink to `.agents/AGENTS.md`.
- Root `CLAUDE.md` is a compatibility symlink to `AGENTS.md`.
- Shared agent/tool config lives in `config.json` and shared skills live in
`skills/`.
- Project-scoped provider discovery files are generated local artifacts. Edit
the canonical files under `.agents/` instead of editing generated tool
directories by hand.
- If you change `.agents/config.json`, `skills/**`, or the shim-generation
workflow, run:
-`pnpm run agents:sync`
-`pnpm run agents:check`
- Do not commit generated provider config or shim outputs under `.claude/`,
`.cursor/`, `.codex/`, `.vscode/`, or `.mcp.json`.
- Durable cross-tool guidance belongs in root/package `AGENTS.md` files or
`skills/**`, not only in tool-specific config directories.
## Commit, PR, and Release Rules
- Commit messages and PR titles must follow Conventional Commits:
`type(scope): description` or `type: description`.
- PR titles are validated by `.github/workflows/validate-pr-title.yml`.
- In PR descriptions, list impacted packages and executed verification commands.
- Release workflow is managed at root with `pnpm run release`.
- Promote `main` to `production` via
`.github/workflows/promote-main-to-production.yml` or
`pnpm run release:cloud`.
- Do not change release/versioning flow without updating this file and impacted
package guides.
## Git and Tooling Notes
- Use `gh search issues` for GitHub issue search.
- Do not use destructive git commands such as `reset --hard` unless explicitly
requested.
- Do not revert unrelated working-tree changes.
- Keep commits focused and atomic.
- Remaining `.cursor/rules/*.mdc` files should stay thin wrappers around shared
docs or skills rather than owning durable repo guidance directly.
description:Use when editing worker/src/constants/default-model-prices.json, packages/shared/src/server/llm/types.ts, pricing tiers, tokenizer IDs, or matchPattern regexes for OpenAI, Anthropic, Bedrock, Vertex, Azure, or Gemini model pricing.
---
# Add Model Price
Use this skill for model pricing changes in `worker/` and shared LLM type
- Updating provider prices, cache pricing, or tier conditions
- Expanding regex coverage for Bedrock, Vertex, Azure, or provider-prefixed
model names
## How to Read This Skill
- Start with [AGENTS.md](AGENTS.md) for the high-level workflow and helper
scripts.
- Then open only the specific reference file that matches the task.
## Reference Map
| Topic | Read this when | File |
| --- | --- | --- |
| Schema and tier rules | You need the entry shape or pricing-tier invariants | [references/schema-and-tiers.md](references/schema-and-tiers.md) |
| Provider sources and price keys | You need official pricing URLs, per-token conversion, or provider-specific usage keys | [references/provider-sources-and-price-keys.md](references/provider-sources-and-price-keys.md) |
| Match patterns | You are editing `matchPattern` regexes or provider coverage | [references/match-patterns.md](references/match-patterns.md) |
| Workflow and validation | You are applying the end-to-end edit process or checking common mistakes | [references/workflow-and-validation.md](references/workflow-and-validation.md) |
Establish consistency and best practices across Langfuse's backend packages (web, worker, packages/shared) using Next.js 14, tRPC, BullMQ, and TypeScript patterns.
## When to Use This Skill
Use this guide when working on:
- Creating or modifying tRPC routers and procedures
- Creating or modifying public API endpoints (REST)
- Creating or modifying BullMQ queue consumers and producers
- Building services with business logic
- Authenticating API requests
- Accessing resources based on entitlements
- Implementing middleware (tRPC, NextAuth, public API)
- Database operations with Prisma (PostgreSQL) or ClickHouse
- Observability with OpenTelemetry, DataDog, logger, and traceException
- Input validation with Zod v4
- Environment configuration from env variables
- Backend testing and refactoring
---
## Quick Start
### UI: New tRPC Feature Checklist (Web)
- [ ]**Router**: Define in `features/[feature]/server/*Router.ts`
- [ ]**Procedures**: Use appropriate procedure type (protected, public)
- [ ]**Authentication**: Use JWT authorization via middlewares.
- [ ]**Entitlement check**: Access resources based on resource and role
- [ ]**Validation**: Zod v4 schema for input
- [ ]**Service**: Business logic in service file
- [ ]**Error handling**: Use traceException wrapper
- [ ]**Tests**: Unit + integration tests in `__tests__/`
- [ ]**Config**: Access via env.mjs
### SDKs: New Public API Endpoint Checklist (Web)
- [ ]**Route file**: Create in `pages/api/public/`
- [ ]**Wrapper**: Use `withMiddlewares` + `createAuthedProjectAPIRoute`
- [ ]**Types**: Define in `features/public-api/types/`
- [ ]**Authentication**: Authorization via basic auth
- [ ]**Validation**: Zod schemas for query/body/response
- [ ]**Versioning**: Versioning in API path and Zod schemas for query/body/response
- [ ]**Fern API Docs**: Update `fern/apis/server/definition/` to match TypeScript types
- [ ]**Tests**: Add end-to-end test in `__tests__/async/`
### New Queue Processor Checklist (Worker)
- [ ]**Processor**: Create in `worker/src/queues/`
- [ ]**Queue types**: Create queue types in `packages/shared/src/server/queues`
- [ ]**Service**: Business logic in `features/` or `worker/src/features/`
- [ ]**Error handling**: Distinguish between errors which should fail queue processing and errors which should result in a succeeded event.
- [ ]**Queue registration**: Add to WorkerManager in app.ts
- [ ]**Tests**: Add vitest tests in worker
---
## Architecture Overview
### Layered Architecture
```
# Web Package (Next.js 14)
┌─ tRPC API ──────────────────┐ ┌── Public REST API ──────────┐
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
- Use unique IDs (`randomUUID()`) to avoid test interference
- Clean up test data or use unique project IDs
- Tests must be independent and runnable in any order
- Prefer scoped cleanup or unique project IDs over global reset helpers
### 8. Always Filter by projectId for Tenant Isolation
```typescript
// ✅ CORRECT: Filter by projectId for tenant isolation
consttrace=awaitprisma.trace.findUnique({
where:{id: traceId,projectId},// Required for multi-tenant data isolation
});
// ✅ CORRECT: ClickHouse queries also require projectId
consttraces=awaitqueryClickhouse({
query:`
SELECT * FROM traces
WHERE project_id = {projectId: String}
AND timestamp >= {startTime: DateTime64(3)}
`,
params:{projectId,startTime},
});
```
### 9. Keep Fern API Definitions in Sync with TypeScript Types
When modifying public API types in `web/src/features/public-api/types/`, the corresponding Fern API definitions in `fern/apis/server/definition/` must be updated to match.
Service layer overview, dependency injection patterns, singleton patterns, repository pattern for data access, service design principles, caching strategies, testing services
Dual database architecture (PostgreSQL via Prisma + ClickHouse via direct client), PostgreSQL CRUD operations, ClickHouse query patterns (queryClickhouse, queryClickhouseStream, upsertClickhouse), repository pattern for complex queries, tenant isolation with projectId filtering, when to use which database
Environment variable validation with Zod, package-specific configs (web/env.mjs with t3-oss/env-nextjs, worker/env.ts, shared/env.ts), NEXT_PUBLIC_LANGFUSE_CLOUD_REGION usage, LANGFUSE_EE_LICENSE_KEY for enterprise features, best practices for env management
Integration tests (Public API with makeZodVerifiedAPICall), tRPC tests (createInnerTRPCContext, appRouter.createCaller), service-level tests (repository/service functions), worker tests (vitest with streams), test isolation principles, running tests (Jest for web, vitest for worker)
description:Shared backend guide for Langfuse's Next.js 14, tRPC, BullMQ, and TypeScript monorepo. Use when creating or reviewing tRPC routers, public REST endpoints, BullMQ queue processors, backend services, middleware, Prisma or ClickHouse data access, OpenTelemetry instrumentation, Zod validation, env configuration, or backend tests across web, worker, or packages/shared.
---
# Backend Development Guidelines
Use this skill for backend and API work across `web/`, `worker/`, and
`packages/shared/`.
## When to Apply
- Creating or modifying tRPC routers and procedures
- Creating or modifying public API endpoints
- Creating or modifying queue processors, producers, or queue-backed workflows
- Building or refactoring backend services and repositories
- Working on backend auth, middleware, validation, or observability
- Updating Prisma or ClickHouse access patterns
- Adding or fixing backend tests
## How to Read This Skill
- Start with [AGENTS.md](AGENTS.md) when the task spans multiple backend areas
or you need the end-to-end checklists.
- Read only the specific reference file that matches the work when the scope is
narrower.
## Reference Map
| Topic | Read this when | File |
| --- | --- | --- |
| Architecture and package boundaries | You need the web/worker/shared split, request flow, or queue lifecycle | [references/architecture-overview.md](references/architecture-overview.md) |
| Routing and controllers | You are writing tRPC procedures, public API routes, or queue entrypoints | [references/routing-and-controllers.md](references/routing-and-controllers.md) |
| Middleware and auth | You are changing request auth, permissions, or middleware composition | [references/middleware-guide.md](references/middleware-guide.md) |
| Services and repositories | You are placing business logic, repository code, or DI patterns | [references/services-and-repositories.md](references/services-and-repositories.md) |
| Database access | You are touching Prisma, ClickHouse, tenant filters, or query patterns | [references/database-patterns.md](references/database-patterns.md) |
| Configuration | You are adding env vars, startup config, or runtime toggles | [references/configuration.md](references/configuration.md) |
| Testing | You are adding or updating backend tests | [references/testing-guide.md](references/testing-guide.md) |
## Full Compiled Guide
Read [AGENTS.md](AGENTS.md) for the complete backend guide with checklists,
directory conventions, imports, architecture, and cross-cutting practices.
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
**Key Principle**: Use PostgreSQL for transactional data and relationships. Use ClickHouse for high-volume analytics and time-series data.
**⚠️ Important**: All queries must filter by `project_id` (or `projectId`) to ensure proper data isolation between tenants. This is essential for the multi-tenant architecture.
description:MUST USE when reviewing ClickHouse schemas, queries, or configurations. Contains 28 rules that MUST be checked before providing recommendations. Always read relevant rule files and cite specific rules in responses.
license:Apache-2.0
metadata:
author:ClickHouse Inc
version:"0.3.0"
---
# ClickHouse Best Practices
Comprehensive guidance for ClickHouse covering schema design, query optimization, and data ingestion. Contains 28 rules across 3 main categories (schema, query, insert), prioritized by impact.
> **Official docs:** [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
## IMPORTANT: How to Apply This Skill
**Before answering ClickHouse questions, follow this priority order:**
1.**Check for applicable rules** in the `rules/` directory
2.**If rules exist:** Apply them and cite them in your response using "Per `rule-name`..."
3.**If no rule exists:** Use the LLM's ClickHouse knowledge or search documentation
4.**If uncertain:** Use web search for current best practices
5.**Always cite your source:** rule name, "general ClickHouse guidance", or URL
**Why rules take priority:** ClickHouse has specific behaviors (columnar storage, sparse indexes, merge tree mechanics) where general database intuition can be misleading. The rules encode validated, ClickHouse-specific guidance.
### For Formal Reviews
When performing a formal review of schemas, queries, or data ingestion:
---
## Review Procedures
### For Schema Reviews (CREATE TABLE, ALTER TABLE)
**Read these rule files in order:**
1.`rules/schema-pk-plan-before-creation.md` - ORDER BY is immutable
2.`rules/schema-pk-cardinality-order.md` - Column ordering in keys
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
---
## 1. Schema Design (schema)
**Impact:** CRITICAL
**Description:** Proper schema design is foundational to ClickHouse performance. ORDER BY is immutable after table creation; wrong choices require full data migration. Includes primary key selection, data types, partitioning strategy, and JSON usage. Column types and ordering can impact query speed by orders of magnitude.
## 2. Query Optimization (query)
**Impact:** CRITICAL
**Description:** Query patterns dramatically affect performance. JOIN algorithms, filtering strategies, skipping indices, and materialized views can reduce query time from minutes to milliseconds. Pre-computed aggregations read thousands of rows instead of billions.
## 3. Insert Strategy (insert)
**Impact:** CRITICAL
**Description:** Each INSERT creates a data part. Single-row inserts overwhelm the merge process. Proper batching (10K-100K rows), async inserts for high-frequency writes, mutation avoidance, and letting background merges work are essential for stable cluster performance.
impactDescription:"Each INSERT creates a part; single-row inserts overwhelm merge process"
tags:[insert, batching, parts, performance]
---
## Batch Inserts Appropriately (10K-100K rows)
**Impact: CRITICAL**
Each INSERT creates a new data part. Single-row or small-batch inserts create thousands of tiny parts, overwhelming the merge process and causing cluster instability.
**Incorrect (single-row or tiny batches):**
```python
# Single-row inserts - creates 10,000 parts!
foreventinevents:
client.execute("INSERT INTO events VALUES",[event])
# Tiny batches - still too many parts
forbatchinchunks(events,100):# 100 rows per INSERT
client.execute("INSERT INTO events VALUES",batch)
```
**Correct (proper batch size):**
```python
# Ideal batch size: 10,000-100,000 rows
BATCH_SIZE=10_000
forbatchinchunks(events,BATCH_SIZE):
client.execute("INSERT INTO events VALUES",batch)
```
**Recommended batch sizes:**
| Threshold | Value |
|-----------|-------|
| Minimum | 1,000 rows |
| Ideal range | 10,000-100,000 rows |
| Insert rate (sync) | ~1 insert per second |
**Validation:**
```sql
-- Monitor part count (>3000 per partition blocks inserts)
SELECTtable,count()asparts,sum(rows)astotal_rows
FROMsystem.parts
WHEREactiveANDdatabase='default'
GROUPBYtable
ORDERBYpartsDESC;
```
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
`ALTER TABLE UPDATE` is a mutation - an asynchronous background process that rewrites entire data parts affected by the change. This is extremely expensive for frequent or large-scale operations.
**Why mutations are problematic:**
- **Write amplification:** Rewrite complete parts even for minor changes
impactDescription:"Forces expensive merge of all parts; let background merges work"
tags:[insert, OPTIMIZE, merge, performance]
---
## Avoid OPTIMIZE TABLE FINAL
**Impact: HIGH**
`OPTIMIZE TABLE ... FINAL` forces immediate merge of all parts into one part per partition. This is resource-intensive and rarely necessary. ClickHouse already performs smart background merges.
**Note:**`OPTIMIZE FINAL` is not the same as `FINAL`. The `FINAL` modifier in SELECT queries may be necessary for deduplicated results in ReplacingMergeTree and is generally fine to use.
**Incorrect (OPTIMIZE FINAL after inserts):**
```sql
-- Running OPTIMIZE FINAL after every batch insert
INSERTINTOeventsSELECT*FROMstaging_events;
OPTIMIZETABLEeventsFINAL;-- Expensive and unnecessary!
title:Use Data Skipping Indices for Non-ORDER BY Filters
impact:HIGH
impactDescription:"Up to 60x faster queries by skipping irrelevant granules"
tags:[query, index, skipping, bloom_filter]
---
## Use Data Skipping Indices for Non-ORDER BY Filters
**Impact: HIGH**
Queries filtering on columns not in ORDER BY cannot use the primary index and result in full scans. Data skipping indices store metadata about blocks and skip granules that definitely don't match.
**Important:** Skip indices should be considered **after** optimizing data types, primary key selection, and materialized views.
**When to use:**
- High overall cardinality but low cardinality within blocks
- Rare values critical for search (error codes, specific IDs)
- Column correlates with primary key
**When NOT to use:**
- As a first optimization step
- Matching values scattered across many blocks
- Without testing on real data
**Incorrect (filtering on non-ORDER BY column):**
```sql
CREATETABLEevents(
event_typeLowCardinality(String),
timestampDateTime,
user_idUInt64-- Not in ORDER BY
)
ENGINE=MergeTree()
ORDERBY(event_type,toDate(timestamp));
-- Query filters on user_id - scans all matching event_type
**Note:** ClickHouse 24.12+ automatically positions smaller tables on the right side. For earlier versions, manually ensure the smaller table is on the RIGHT.
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
impactDescription:"Dictionaries and denormalization shift work from query time to insert time"
tags:[query, JOIN, dictionary, denormalization]
---
## Consider Alternatives to JOINs
**Impact: CRITICAL**
Repeated JOINs to dimension tables add overhead. Dictionaries or denormalization shift computational work from query time to insert/pre-processing time.
**Incorrect (JOIN on every query):**
```sql
-- JOIN on every query
SELECTo.order_id,c.name,c.email
FROMorderso
JOINcustomerscONc.id=o.customer_id
WHEREo.created_at>'2024-01-01';
```
**Correct - Dictionary Lookup:**
```sql
-- Create dictionary
CREATEDICTIONARYcustomer_dict(
idUInt64,
nameString,
emailString
)
PRIMARYKEYid
SOURCE(CLICKHOUSE(TABLE'customers'))
LAYOUT(HASHED())
LIFETIME(MIN300MAX360);
-- Use dictGet instead of JOIN (uses direct join algorithm - fastest)
| Dictionary | Frequent lookups to small dimension | Fastest (in-memory) |
| Denormalization | Analytics always need enriched data | Fast (no join at query) |
| IN subquery | Existence filtering | Often faster than JOIN |
| JOIN | Infrequent or complex joins | Acceptable |
**Critical dictionary caveat:** Dictionaries silently deduplicate duplicate keys, retaining only the final value. Only use when source has unique keys.
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
impactDescription:"Joining full tables then filtering wastes resources"
tags:[query, JOIN, filtering, subquery]
---
## Filter Tables Before Joining
**Impact: CRITICAL**
Joining full tables then filtering wastes resources. Add filtering in `WHERE` or `JOIN ON` clauses. If automatic pushdown fails, restructure as a subquery.
**Incorrect (join then filter):**
```sql
-- Joins entire tables, then filters
SELECTo.order_id,c.name,o.total
FROMorderso
JOINcustomerscONc.id=o.customer_id
WHEREo.created_at>'2024-01-01'ANDc.country='US';
```
**Correct (filter in subqueries before joining):**
```sql
-- Filter in subqueries before joining
SELECTo.order_id,c.name,o.total
FROM(
SELECTorder_id,customer_id,total
FROMorders
WHEREcreated_at>'2024-01-01'
)o
JOIN(
SELECTid,name
FROMcustomers
WHEREcountry='US'
)cONc.id=o.customer_id;
```
**Even better - aggregate before joining:**
```sql
SELECTc.country,o.total_revenue
FROM(
SELECTcustomer_id,sum(total)astotal_revenue
FROMorders
WHEREcreated_at>'2024-01-01'
GROUPBYcustomer_id
)o
JOINcustomerscONc.id=o.customer_id;
```
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
Incremental MVs automatically apply the view's query to new data blocks at insert time. Results are written to a target table and partial results merge over time.
**Incorrect (full aggregation on every query):**
```sql
-- Full aggregation on every dashboard load
SELECT
event_type,
toStartOfHour(timestamp)ashour,
count()asevents,
uniq(user_id)asunique_users
FROMevents
WHEREtimestamp>=now()-INTERVAL7DAY
GROUPBYevent_type,hour;
-- Scans 7 days of data every time (billions of rows)
```
**Correct (incremental MV with pre-aggregation):**
```sql
-- Create target table for aggregated data
CREATETABLEevents_hourly(
event_typeLowCardinality(String),
hourDateTime,
eventsAggregateFunction(count),
unique_usersAggregateFunction(uniq,UInt64)
)
ENGINE=AggregatingMergeTree()
ORDERBY(event_type,hour);
-- Create materialized view to populate incrementally
impactDescription:"Field-level querying for semi-structured data; use typed columns for known schemas"
tags:[schema, JSON, semi-structured, flexibility]
---
## Use JSON Type for Dynamic Schemas
**Impact: MEDIUM**
ClickHouse's JSON type splits JSON objects into separate sub-columns, enabling field-level query optimization. Use it for truly dynamic data, not everything.
**Incorrect (schema bloat or opaque String):**
```sql
-- BAD: Hundreds of nullable columns for event properties
CREATETABLEevents(
event_idUUID,
prop_page_urlNullable(String),
prop_button_idNullable(String),
-- ... 100 more nullable columns
)
-- BAD: JSON as String when you need field queries
CREATETABLEevents(
event_idUUID,
propertiesString-- No field-level optimization
)
```
**Correct (JSON for dynamic, typed for known):**
```sql
-- Use JSON type for dynamic properties
CREATETABLEevents(
event_idUUIDDEFAULTgenerateUUIDv4(),
event_typeLowCardinality(String),
timestampDateTimeDEFAULTnow(),
propertiesJSON-- Flexible schema with type inference
Too many distinct partition values create excessive data parts, eventually triggering "too many parts" errors. ClickHouse enforces limits via `max_parts_in_total` and `parts_to_throw_insert` settings.
**Incorrect (high cardinality partitioning):**
```sql
-- High cardinality = too many partitions
CREATETABLEevents(...)
ENGINE=MergeTree()
PARTITIONBYuser_id-- Millions of partitions!
ORDERBY(timestamp);
-- Daily partitions can grow unbounded over years
CREATETABLElogs(...)
ENGINE=MergeTree()
PARTITIONBYtoDate(timestamp)-- 3650 partitions over 10 years
ORDERBY(service,timestamp);
```
**Correct (bounded cardinality):**
```sql
-- Monthly partitions = 12 per year, bounded cardinality
CREATETABLEevents(
timestampDateTime,
event_typeLowCardinality(String),
user_idUInt64
)
ENGINE=MergeTree()
PARTITIONBYtoStartOfMonth(timestamp)
ORDERBY(event_type,timestamp);
```
**Validation:**
```sql
-- Check partition count and health
SELECT
partition,
count()asparts,
sum(rows)asrows,
formatReadableSize(sum(bytes_on_disk))assize
FROMsystem.parts
WHEREtable='events'ANDactive
GROUPBYpartition
ORDERBYpartition;
-- Warning signs: hundreds or thousands of partitions
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
impactDescription:"Enables granule skipping; high-cardinality first prevents index pruning"
tags:[schema, primary-key, cardinality, ORDER BY]
---
## Order Columns by Cardinality (Low to High)
**Impact: CRITICAL**
Since the sparse primary index operates on data blocks (granules) rather than individual rows, low-cardinality leading columns create more useful index entries that can skip entire blocks. Place lower-cardinality columns before higher-cardinality ones in the ordering key.
**Incorrect (high cardinality first):**
```sql
-- UUID first means no pruning benefit
CREATETABLEevents(...)
ENGINE=MergeTree()
ORDERBY(event_id,event_type,timestamp);
-- Every granule has different event_id values, index can't skip anything
| 2nd | Date (coarse granularity) | toDate(timestamp) |
| 3rd+ | Medium-High | user_id, session_id |
| Last | High (if needed) | event_id, uuid |
**Tip:** Use `toDate(timestamp)` instead of raw `DateTime` columns when day-level filtering suffices - this reduces index size from 32-bit to 16-bit representations.
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
impactDescription:"Skipping prefix columns prevents index usage"
tags:[schema, primary-key, WHERE, query]
---
## Filter on ORDER BY Columns in Queries
**Impact: CRITICAL**
Even with good schema design, queries must use ORDER BY columns to benefit. Skipping prefix columns or filtering on non-ORDER BY columns prevents index usage.
**Incorrect (skips prefix or uses non-ORDER BY columns):**
```sql
-- Given: ORDER BY (tenant_id, event_type, timestamp)
-- Skips prefix columns - can't use index effectively
SELECT*FROMeventsWHEREevent_type='click';
-- Filter on column not in ORDER BY - full table scan
SELECT*FROMeventsWHEREuser_agentLIKE'%Chrome%';
```
**Correct (uses ORDER BY prefix):**
```sql
-- Given: ORDER BY (tenant_id, event_type, timestamp)
-- Full prefix match - best performance
SELECT*FROMevents
WHEREtenant_id=123ANDevent_type='click';
-- Partial prefix - still uses index
SELECT*FROMeventsWHEREtenant_id=123;
-- Range on later column after equality on earlier
impactDescription:"ORDER BY is immutable; wrong choice requires full data migration"
tags:[schema, primary-key, ORDER BY]
---
## Plan PRIMARY KEY Before Table Creation
**Impact: CRITICAL** (immutable after creation)
ClickHouse's ORDER BY clause defines physical data ordering and the sparse index. Unlike other databases, **ORDER BY cannot be modified after table creation**. A wrong choice requires creating a new table and migrating all data.
**Incorrect (arbitrary ORDER BY without query analysis):**
```sql
-- Creating table without analyzing query patterns
CREATETABLEevents(
event_idUUID,
user_idUInt64,
timestampDateTime
)
ENGINE=MergeTree()
ORDERBY(event_id);-- Chosen arbitrarily
-- Later: "Most queries filter by user_id!"
-- Cannot fix with: ALTER TABLE events MODIFY ORDER BY (user_id, timestamp)
-- ERROR: Cannot modify ORDER BY
```
**Correct (query-driven ORDER BY selection):**
```sql
-- Step 1: Document query patterns BEFORE creating table
/*
Query Analysis:
- 60% of queries: WHERE user_id = ? AND timestamp BETWEEN ? AND ?
- 25% of queries: WHERE event_type = ? AND timestamp > ?
- 15% of queries: WHERE event_id = ?
Conclusion: user_id and event_type are primary filters
*/
-- Step 2: Create table with correct ORDER BY
CREATETABLEevents(
event_idUUIDDEFAULTgenerateUUIDv4(),
user_idUInt64,
event_typeLowCardinality(String),
timestampDateTime,
event_dateDateDEFAULTtoDate(timestamp)
)
ENGINE=MergeTree()
PARTITIONBYtoYYYYMM(event_date)
ORDERBY(user_id,event_date,event_id);
```
**Pre-creation checklist:**
- [ ] Listed top 5-10 query patterns
- [ ] Identified columns in WHERE clauses with frequency
- [ ] Prioritized columns that exclude large numbers of rows
- [ ] Ordered columns by cardinality (low first, high last)
- [ ] Limited to 4-5 key columns (typically sufficient)
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
impactDescription:"Columns not in ORDER BY cause full table scans"
tags:[schema, primary-key, WHERE, filtering]
---
## Prioritize Filter Columns in ORDER BY
**Impact: CRITICAL**
Prioritize columns frequently used in query filters (WHERE clause), especially those that exclude large numbers of rows. Queries filtering on columns not in ORDER BY result in full table scans.
**Incorrect (ORDER BY doesn't match query patterns):**
```sql
-- If most queries filter by tenant_id:
CREATETABLEevents(...)
ENGINE=MergeTree()
ORDERBY(event_id);-- Queries by tenant_id will full-scan!
impactDescription:"Nullable adds storage overhead; use DEFAULT values instead"
tags:[schema, data-types, Nullable, DEFAULT]
---
## Avoid Nullable Unless Semantically Required
**Impact: HIGH**
Nullable columns maintain a separate UInt8 column for tracking null values, increasing storage and degrading performance. Use DEFAULT values instead when feasible.
**Incorrect (Nullable everywhere):**
```sql
CREATETABLEusers(
idNullable(UInt64),-- IDs should never be null
nameNullable(String),-- Empty string is fine
ageNullable(UInt8),-- 0 is a valid default
login_countNullable(UInt32)-- 0 is a valid default
)
```
**Correct (DEFAULT values, Nullable only when semantic):**
```sql
CREATETABLEusers(
idUInt64,-- Never null
nameStringDEFAULT'',-- Empty = unknown
ageUInt8DEFAULT0,-- 0 = unknown
login_countUInt32DEFAULT0,-- 0 = never logged in
deleted_atNullable(DateTime),-- NULL = not deleted (semantic!)
parent_idNullable(UInt64)-- NULL = no parent (semantic!)
impactDescription:"Insert-time validation and natural ordering; 1-2 bytes storage"
tags:[schema, data-types, Enum, validation]
---
## Use Enum for Finite Value Sets
**Impact: MEDIUM**
Enum types provide validation at insert time and enable queries that exploit natural ordering. Use Enum8 (up to 256 values) or Enum16 (up to 65,536 values).
**Incorrect (String without validation):**
```sql
CREATETABLEorders(
statusString-- No validation, typos like "shiped" allowed
Reserve `FixedString` for strictly fixed-length data (e.g., 2-char country codes). For most low-cardinality text, `LowCardinality(String)` outperforms `FixedString`.
impactDescription:"2-10x storage reduction; enables compression and correct semantics"
tags:[schema, data-types, storage]
---
## Use Native Types Instead of String
**Impact: CRITICAL**
Using String for all data wastes storage, prevents compression optimization, and makes comparisons slower. ClickHouse's column-oriented architecture benefits directly from optimal type selection.
This is the canonical shared review checklist for Langfuse.
## Database Migrations
### ClickHouse
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/clustered` directory should include `ON CLUSTER default` and should use `Replicated` merge tree table types.
- E.g. `ReplacingMergeTree` is likely an error while `ReplicatedReplacingMergeTree` would be correct in most cases.
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/unclustered` directory must not include `ON CLUSTER` statements and must not use `Replicated` merge tree table types.
- Migrations in `packages/shared/clickhouse/migrations/clustered` should match their counterparts in `packages/shared/clickhouse/migrations/unclustered` aside from the restrictions listed above.
- When adding new indexes on ClickHouse, ensure that there is a corresponding `MATERIALIZE INDEX` statement in the same migration. The materialization can use `SETTINGS mutations_sync = 2` if they operate on smaller tables, but may timeout otherwise.
- All ClickHouse queries on project-scoped tables (traces, observations, scores, events, sessions, etc.) must include `WHERE project_id = {projectId: String}` filter to ensure proper tenant isolation and that queries only access data from the intended project.
- For operations on the `events` table, you must never use the `FINAL` keyword as it kills performance. `events` is built so that `FINAL` is never required.
### Postgres
- Most `schema.prisma` changes should produce a change in `packages/shared/prisma/migrations`.
- All Prisma queries on project-scoped tables must include `projectId` in the WHERE clause (e.g., `where: { id: traceId, projectId }`) to ensure proper tenant isolation and that queries only access data from the intended project.
### Environment Variables
- Environment variables should be imported from the `env.mjs/ts` file of the respective package and not from `process.env.*` to ensure validation and typing.
## Redis Invocations
- Highlight usage of `redis.call` invocations. Those may have suboptimal redis cluster routing and will raise errors. Instead, use the native call patterns.
Example: `await redis?.call("SET", key, "1", "NX", "EX", TTLSeconds);` should use `await redis?.set(key, "1", "EX", TTLSeconds, "NX");` instead.
## Langfuse Cloud
- When attempting to confirm if the current environment is Langfuse Cloud in the frontend, use the `useLangfuseCloudRegion` hook and never environment variables directly.
## Banner Height System
- Use `top-banner-offset` instead of `top-0` for any elements that are positioned `sticky`, `fixed`, or `absolute` with a global reference point (e.g., `top-0`). This ensures proper spacing when system banners (payment, maintenance, etc.) are displayed.
- The banner height is managed through CSS variables (`--banner-height` and `--banner-offset`) defined in `web/src/styles/globals.css`.
- Banner components (like PaymentBanner) dynamically update `--banner-height` using ResizeObserver to track their actual height, ensuring accurate positioning even when banners resize (e.g., on mobile wrapping).
- Available Tailwind utilities:
-`top-banner-offset` / `pt-banner-offset` - For sticky/fixed/absolute positioning and padding
-`h-screen-with-banner` / `min-h-screen-with-banner` - For full-height containers accounting for banners
## JavaScript / TypeScript Style
- use concat instead of spread to avoid stack overflow with large arrays
## Seeder
- make sure that for new features with data model changes, the database seeder is adjusted.
## API Documentation
- Whenever a file in `web/src/features/public-api/types` changes, the `fern/apis` definition probably needs to be adjusted, too.
-`nullish` types should map to `optional<nullable<T>>` in fern.
-`nullable` types should map to `nullable<T>` in fern.
-`optional` types should map to `optional<T>` in fern.
// Root package.json - ONLY delegates, no task logic
{
"scripts":{
"build":"turbo run build",
"lint":"turbo run lint",
"test":"turbo run test"
}
}
```
```json
// DO NOT DO THIS - defeats parallelization
// Root package.json
{
"scripts":{
"build":"cd apps/web && next build && cd ../api && tsc",
"lint":"eslint apps/ packages/",
"test":"vitest"
}
}
```
Root Tasks (`//#taskname`) are ONLY for tasks that truly cannot exist in packages (rare).
## Secondary Rule: `turbo run` vs `turbo`
**Always use `turbo run` when the command is written into code:**
```json
// package.json - ALWAYS "turbo run"
{
"scripts":{
"build":"turbo run build"
}
}
```
```yaml
# CI workflows - ALWAYS "turbo run"
- run:turbo run build --affected
```
**The shorthand `turbo <tasks>` is ONLY for one-off terminal commands** typed directly by humans or agents. Never write `turbo build` into package.json, CI, or scripts.
"changeset:publish":"turbo run build && changeset publish"
}
}
```
### `prebuild` Scripts That Manually Build Dependencies
Scripts like `prebuild` that manually build other packages bypass Turborepo's dependency graph.
```json
// WRONG - manually building dependencies
{
"scripts":{
"prebuild":"cd ../../packages/types && bun run build && cd ../utils && bun run build",
"build":"next build"
}
}
```
**However, the fix depends on whether workspace dependencies are declared:**
1.**If dependencies ARE declared** (e.g., `"@repo/types": "workspace:*"` in package.json), remove the `prebuild` script. Turbo's `dependsOn: ["^build"]` handles this automatically.
2.**If dependencies are NOT declared**, the `prebuild` exists because `^build` won't trigger without a dependency relationship. The fix is to:
- Add the dependency to package.json: `"@repo/types": "workspace:*"`
- Then remove the `prebuild` script
```json
// CORRECT - declare dependency, let turbo handle build order
// package.json
{
"dependencies":{
"@repo/types":"workspace:*",
"@repo/utils":"workspace:*"
},
"scripts":{
"build":"next build"
}
}
// turbo.json
{
"tasks":{
"build":{
"dependsOn":["^build"]
}
}
}
```
**Key insight:**`^build` only runs build in packages listed as dependencies. No dependency declaration = no automatic build ordering.
### Overly Broad `globalDependencies`
`globalDependencies` affects ALL tasks in ALL packages via the **global hash** — tasks cannot opt out of specific files, even with negation globs in `inputs`. Be specific.
```json
// WRONG - heavy hammer, affects all hashes
{
"globalDependencies":["**/.env.*local"]
}
// BETTER - move to task-level inputs
{
"globalDependencies":[".env"],
"tasks":{
"build":{
"inputs":["$TURBO_DEFAULT$",".env*"],
"outputs":["dist/**"]
}
}
}
```
With `futureFlags.globalConfiguration`, this problem is reduced because `global.inputs` files are folded into each task's inputs (not the global hash). Tasks can exclude specific files:
```json
// BEST - global.inputs with per-task exclusion
{
"futureFlags":{"globalConfiguration":true},
"global":{
"inputs":[".env"]
},
"tasks":{
"build":{"outputs":["dist/**"]},
"lint":{
"inputs":["$TURBO_DEFAULT$","!$TURBO_ROOT$/.env"]
}
}
}
```
### Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed. Turborepo supports shared configuration patterns.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks":{
"build":{
"env":["API_URL","DATABASE_URL"],
"inputs":["$TURBO_DEFAULT$",".env*"]
},
"test":{
"env":["API_URL","DATABASE_URL"],
"inputs":["$TURBO_DEFAULT$",".env*"]
},
"dev":{
"env":["API_URL","DATABASE_URL"],
"inputs":["$TURBO_DEFAULT$",".env*"],
"cache":false,
"persistent":true
}
}
}
// BETTER - use globalEnv and globalDependencies for shared config
{
"globalEnv":["API_URL","DATABASE_URL"],
"globalDependencies":[".env*"],
"tasks":{
"build":{},
"test":{},
"dev":{
"cache":false,
"persistent":true
}
}
}
```
**When to use global vs task-level:**
-`globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
### NOT an Anti-Pattern: Large `env` Arrays
A large `env` array (even 50+ variables) is **not** a problem. It usually means the user was thorough about declaring their build's environment dependencies. Do not flag this as an issue.
### Using `--parallel` Flag
The `--parallel` flag bypasses Turborepo's dependency graph. If tasks need parallel execution, configure `dependsOn` correctly instead.
```bash
# WRONG - bypasses dependency graph
turbo run lint --parallel
# CORRECT - configure tasks to allow parallel execution
# In turbo.json, set dependsOn appropriately (or use transit nodes)
turbo run lint
```
### Package-Specific Task Overrides in Root turbo.json
When multiple packages need different task configurations, use **Package Configurations** (`turbo.json` in each package) instead of cluttering root `turbo.json` with `package#task` overrides.
```json
// WRONG - root turbo.json with many package-specific overrides
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG: build produces files but they're not cached
{
"tasks":{
"build":{
"dependsOn":["^build"]
}
}
}
// CORRECT: build outputs are cached
{
"tasks":{
"build":{
"dependsOn":["^build"],
"outputs":["dist/**"]
}
}
}
```
Common outputs by framework:
- Next.js: `[".next/**", "!.next/cache/**"]`
- Vite/Rollup: `["dist/**"]`
- tsc: `["dist/**"]` or custom `outDir`
**TypeScript `--noEmit` can still produce cache files:**
When `incremental: true` in tsconfig.json, `tsc --noEmit` writes `.tsbuildinfo` files even without emitting JS. Check the tsconfig before assuming no outputs:
```json
// If tsconfig has incremental: true, tsc --noEmit produces cache files
{
"tasks":{
"typecheck":{
"outputs":["node_modules/.cache/tsbuildinfo.json"]// or wherever tsBuildInfoFile points
}
}
}
```
To determine correct outputs for TypeScript tasks:
1. Check if `incremental` or `composite` is enabled in tsconfig
2. Check `tsBuildInfoFile` for custom cache location (default: alongside `outDir` or in project root)
3. If no incremental mode, `tsc --noEmit` produces no files
### `^build` vs `build` Confusion
```json
{
"tasks":{
// ^build = run build in DEPENDENCIES first (other packages this one imports)
"build":{
"dependsOn":["^build"]
},
// build (no ^) = run build in SAME PACKAGE first
"test":{
"dependsOn":["build"]
},
// pkg#task = specific package's task
"deploy":{
"dependsOn":["web#build"]
}
}
}
```
### Environment Variables Not Hashed
```json
// WRONG: API_URL changes won't cause rebuilds
{
"tasks":{
"build":{
"outputs":["dist/**"]
}
}
}
// CORRECT: API_URL changes invalidate cache
{
"tasks":{
"build":{
"outputs":["dist/**"],
"env":["API_URL","API_KEY"]
}
}
}
```
### `.env` Files Not in Inputs
Turbo does NOT load `.env` files - your framework does. But Turbo needs to know about changes:
```json
// WRONG: .env changes don't invalidate cache
{
"tasks":{
"build":{
"env":["API_URL"]
}
}
}
// CORRECT: .env file changes invalidate cache
{
"tasks":{
"build":{
"env":["API_URL"],
"inputs":["$TURBO_DEFAULT$",".env",".env.*"]
}
}
}
```
### Root `.env` File in Monorepo
A `.env` file at the repo root is an anti-pattern — even for small monorepos or starter templates. It creates implicit coupling between packages and makes it unclear which packages depend on which variables.
```
// WRONG - root .env affects all packages implicitly
my-monorepo/
├── .env # Which packages use this?
├── apps/
│ ├── web/
│ └── api/
└── packages/
// CORRECT - .env files in packages that need them
my-monorepo/
├── apps/
│ ├── web/
│ │ └── .env # Clear: web needs DATABASE_URL
│ └── api/
│ └── .env # Clear: api needs API_KEY
└── packages/
```
**Problems with root `.env`:**
- Unclear which packages consume which variables
- All packages get all variables (even ones they don't need)
- Cache invalidation is coarse-grained (root .env change invalidates everything)
- Security risk: packages may accidentally access sensitive vars meant for others
- Bad habits start small — starter templates should model correct patterns
**If you must share variables**, use `globalEnv` to be explicit about what's shared, and document why.
### Strict Mode Filtering CI Variables
By default, Turborepo filters environment variables to only those in `env`/`globalEnv`. CI variables may be missing:
```json
// If CI scripts need GITHUB_TOKEN but it's not in env:
{
"globalPassThroughEnv":["GITHUB_TOKEN","CI"],
"tasks":{...}
}
```
Or use `--env-mode=loose` (not recommended for production).
### Shared Code in Apps (Should Be a Package)
```
// WRONG: Shared code inside an app
apps/
web/
shared/ # This breaks monorepo principles!
utils.ts
// CORRECT: Extract to a package
packages/
utils/
src/utils.ts
```
### Accessing Files Across Package Boundaries
```typescript
// WRONG: Reaching into another package's internals
Add a `transit` task if you have tasks that need parallel execution with cache invalidation (see below).
### Dev Task with `^dev` Pattern (for `turbo watch`)
A `dev` task with `dependsOn: ["^dev"]` and `persistent: false` in root turbo.json may look unusual but is **correct for `turbo watch` workflows**:
```json
// Root turbo.json
{
"tasks":{
"dev":{
"dependsOn":["^dev"],
"cache":false,
"persistent":false// Packages have one-shot dev scripts
}
}
}
// Package turbo.json (apps/web/turbo.json)
{
"extends":["//"],
"tasks":{
"dev":{
"persistent":true// Apps run long-running dev servers
}
}
}
```
**Why this works:**
- **Packages** (e.g., `@acme/db`, `@acme/validators`) have `"dev": "tsc"` — one-shot type generation that completes quickly
- **Apps** override with `persistent: true` for actual dev servers (Next.js, etc.)
- **`turbo watch`** re-runs the one-shot package `dev` scripts when source files change, keeping types in sync
**Intended usage:** Run `turbo watch dev` (not `turbo run dev`). Watch mode re-executes one-shot tasks on file changes while keeping persistent tasks running.
**Alternative pattern:** Use a separate task name like `prepare` or `generate` for one-shot dependency builds to make the intent clearer:
```json
{
"tasks":{
"prepare":{
"dependsOn":["^prepare"],
"outputs":["dist/**"]
},
"dev":{
"dependsOn":["prepare"],
"cache":false,
"persistent":true
}
}
}
```
### Transit Nodes for Parallel Tasks with Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must invalidate cache when dependency source code changes.
**The problem with `dependsOn: ["^taskname"]`:**
- Forces sequential execution (slow)
**The problem with `dependsOn: []` (no dependencies):**
- Allows parallel execution (fast)
- But cache is INCORRECT - changing dependency source won't invalidate cache
**Transit Nodes solve both:**
```json
{
"tasks":{
"transit":{"dependsOn":["^transit"]},
"my-task":{"dependsOn":["transit"]}
}
}
```
The `transit` task creates dependency relationships without matching any actual script, so tasks run in parallel with correct cache invalidation.
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
### With Environment Variables
```json
{
"globalEnv":["NODE_ENV"],
"globalDependencies":[".env"],
"tasks":{
"build":{
"dependsOn":["^build"],
"outputs":["dist/**"],
"env":["API_URL","DATABASE_URL"]
}
}
}
```
With `futureFlags.globalConfiguration`, the same config moves global settings under `global` — and `.env` becomes a per-task input instead of a global hash input:
description:Load Turborepo skill for creating workflows, tasks, and pipelines in monorepos. Use when users ask to "create a workflow", "make a task", "generate a pipeline", or set up build orchestration.
---
Load the Turborepo skill and help with monorepo task orchestration: creating workflows, configuring tasks, setting up pipelines, and optimizing builds.
## Workflow
### Step 1: Load turborepo skill
```
skill({ name: 'turborepo' })
```
### Step 2: Identify task type from user request
Analyze $ARGUMENTS to determine:
- **Topic**: configuration, caching, filtering, environment, CI, or CLI
- **Task type**: new setup, debugging, optimization, or implementation
Use decision trees in SKILL.md to select the relevant reference files.
### Step 3: Read relevant reference files
Based on task type, read from `references/<topic>/`:
For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues.
### Enable Go-to-Definition
For Compiled Packages, enable declaration maps:
```json
// tsconfig.json
{
"compilerOptions":{
"declaration":true,
"declarationMap":true
}
}
```
This creates `.d.ts` and `.d.ts.map` files for IDE navigation.
### No Root tsconfig.json Needed
Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts.
### Avoid TypeScript Project References
They add complexity and another caching layer. Turborepo handles dependencies better.
When `futureFlags.globalConfiguration` is enabled, `global.inputs` files are **not** part of the global hash. Instead, they are prepended to every task's `inputs` and folded into the **task hash**. This is a fundamental change from `globalDependencies`.
**With `globalDependencies` (default):**
```
task cache key = hash(global hash, task hash)
↑ includes globalDependencies file hashes
```
Changing a `globalDependencies` file invalidates **every** task, regardless of task-level `inputs`. There is no way for a task to opt out.
In this example, changing `tsconfig.json` invalidates `build` (it's in the task's inputs) but **not**`lint` (which explicitly excludes it). With `globalDependencies`, both would have been invalidated.
## What Gets Cached
1.**File outputs** - files/directories specified in `outputs`
2.**Task logs** - stdout/stderr for replay on cache hit
```json
{
"tasks":{
"build":{
"outputs":["dist/**",".next/**"]
}
}
}
```
## Local Cache Location
```
.turbo/cache/
├── <hash1>.tar.zst # compressed outputs
├── <hash2>.tar.zst
└── ...
```
Add `.turbo` to `.gitignore`.
## Cache Restoration
On cache hit, Turborepo:
1. Extracts archived outputs to their original locations
2. Replays the logged stdout/stderr
3. Reports the task as cached (shows `FULL TURBO` in output)
## Example Flow
```bash
# First run - executes build, caches result
turbo build
# → packages/ui: cache miss, executing...
# → packages/web: cache miss, executing...
# Second run - same inputs, restores from cache
turbo build
# → packages/ui: cache hit, replaying output
# → packages/web: cache hit, replaying output
# → FULL TURBO
```
## Key Points
- Cache is content-addressed (based on input hash, not timestamps)
- Empty `outputs` array means task runs but nothing is cached
- Tasks without `outputs` key cache nothing (use `"outputs": []` to be explicit)
Shows cache status for each task without running them.
### `--force`
Skip reading cache, re-execute all tasks:
```bash
turbo build --force
```
Useful to verify tasks actually work (not just cached results).
## Unexpected Cache Misses
**Symptom:** Task runs when you expected a cache hit.
### Environment Variable Changed
Check if an env var in the `env` key changed:
```json
{
"tasks":{
"build":{
"env":["API_URL","NODE_ENV"]
}
}
}
```
Different `API_URL` between runs = cache miss.
### .env File Changed
`.env` files aren't tracked by default. Add to `inputs`:
```json
{
"tasks":{
"build":{
"inputs":["$TURBO_DEFAULT$",".env",".env.local"]
}
}
}
```
Or use `globalDependencies` for repo-wide env files:
```json
{
"globalDependencies":[".env"]
}
```
With `futureFlags.globalConfiguration`, use `global.inputs` instead. The key difference: `global.inputs` files are folded into each task's hash individually (not the global hash), so tasks can exclude specific files with negation globs.
```json
{
"futureFlags":{"globalConfiguration":true},
"global":{
"inputs":[".env"]
}
}
```
### Lockfile Changed
Installing/updating packages changes the global hash.
### Source Files Changed
Any file in the package (or in `inputs`) triggers a miss.
### turbo.json Changed
Config changes invalidate the global hash.
## Incorrect Cache Hits
**Symptom:** Cached output is stale/wrong.
### Missing Environment Variable
Task uses an env var not listed in `env`:
```javascript
// build.js
constapiUrl=process.env.API_URL;// not tracked!
```
Fix: add to task config:
```json
{
"tasks":{
"build":{
"env":["API_URL"]
}
}
}
```
### Missing File in Inputs
Task reads a file outside default inputs:
```json
{
"tasks":{
"build":{
"inputs":[
"$TURBO_DEFAULT$",
"../../shared-config.json"// file outside package
]
}
}
}
```
## Useful Flags
```bash
# Only show output for cache misses
turbo build --output-logs=new-only
# Show output for everything (debugging)
turbo build --output-logs=full
# See why tasks are running
turbo build --verbosity=2
```
## Debugging with `globalConfiguration` Enabled
When `futureFlags.globalConfiguration` is on, `global.inputs` files appear in per-task hash inputs (not the global hash). If you're getting unexpected cache misses:
1. Check `--summarize` output — global input files will show up in the **task inputs** section, not the global hash section
2. Verify tasks aren't accidentally excluding global inputs via negation globs in `inputs`
3. Remember that toggling the `globalConfiguration` flag itself invalidates all caches (the flag value is part of the global hash)
If you're getting unexpected cache **hits** after changing a global input file, the task may be excluding that file with a negation glob. Check the task's `inputs` for `!$TURBO_ROOT$/...` patterns.
## Quick Checklist
Cache miss when expected hit:
1. Run with `--summarize`, compare with previous run
General principles for running Turborepo in continuous integration environments.
## Core Principles
### Always Use `turbo run` in CI
**Never use the `turbo <tasks>` shorthand in CI or scripts.** Always use `turbo run`:
```bash
# CORRECT - Always use in CI, package.json, scripts
turbo run build test lint
# WRONG - Shorthand is only for one-off terminal commands
turbo build test lint
```
The shorthand `turbo <tasks>` is only for one-off invocations typed directly in terminal by humans or agents. Anywhere the command is written into code (CI, package.json, scripts), use `turbo run`.
### Enable Remote Caching
Remote caching dramatically speeds up CI by sharing cached artifacts across runs.
Required environment variables:
```bash
TURBO_TOKEN=your_vercel_token
TURBO_TEAM=your_team_slug
```
### Use --affected for PR Builds
The `--affected` flag only runs tasks for packages changed since the base branch:
```bash
turbo run build test --affected
```
This requires Git history to compute what changed.
## Git History Requirements
### Fetch Depth
`--affected` needs access to the merge base. Shallow clones break this.
```yaml
# GitHub Actions
- uses:actions/checkout@v4
with:
fetch-depth:2# Minimum for --affected
# Use 0 for full history if merge base is far
```
### Why Shallow Clones Break --affected
Turborepo compares the current HEAD to the merge base with `main`. If that commit isn't fetched, `--affected` falls back to running everything.
**Requires git history** - shallow clones may fall back to running all tasks.
## Execution Control
### `--dry` / `--dry=json`
Preview what would run without executing.
```bash
turbo build --dry # human-readable
turbo build --dry=json # machine-readable
```
### `--force`
Ignore all cached artifacts, re-run everything.
```bash
turbo build --force
```
### `--concurrency`
Limit parallel task execution.
```bash
turbo build --concurrency=4# max 4 tasks
turbo build --concurrency=50% # 50% of CPU cores
```
### `--continue`
Keep running other tasks when one fails.
```bash
turbo build test --continue
```
### `--only`
Run only the specified task, skip its dependencies.
```bash
turbo build --only # skip running dependsOn tasks
```
### `--parallel` (Discouraged)
Ignores task graph dependencies, runs all tasks simultaneously. **Avoid using this flag**—if tasks need to run in parallel, configure `dependsOn` correctly instead. Using `--parallel` bypasses Turborepo's dependency graph, which can cause race conditions and incorrect builds.
Configuration reference for Turborepo. Full docs: https://turborepo.dev/docs/reference/configuration
## File Location
Root `turbo.json` lives at repo root, sibling to root `package.json`:
```
my-monorepo/
├── turbo.json # Root configuration
├── package.json
└── packages/
└── web/
├── turbo.json # Package Configuration (optional)
└── package.json
```
## Always Prefer Package Tasks Over Root Tasks
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
Package tasks enable parallelization, individual caching, and filtering. Define scripts in each package's `package.json`:
```json
// packages/web/package.json
{
"scripts":{
"build":"next build",
"lint":"eslint .",
"test":"vitest",
"typecheck":"tsc --noEmit"
}
}
// packages/api/package.json
{
"scripts":{
"build":"tsc",
"lint":"eslint .",
"test":"vitest",
"typecheck":"tsc --noEmit"
}
}
```
```json
// Root package.json - delegates to turbo
{
"scripts":{
"build":"turbo run build",
"lint":"turbo run lint",
"test":"turbo run test",
"typecheck":"turbo run typecheck"
}
}
```
When you run `turbo run lint`, Turborepo finds all packages with a `lint` script and runs them **in parallel**.
**Root Tasks are a fallback**, not the default. Only use them for tasks that truly cannot run per-package (e.g., repo-level CI scripts, workspace-wide config generation).
```json
// AVOID: Task logic in root defeats parallelization
**Deprecated**: The daemon is no longer used for `turbo run` and this option will be removed in version 3.0. The daemon is still used by `turbo watch` and the Turborepo LSP.
## envMode
How unspecified env vars are handled. Default: `"strict"`.
```json
{
"envMode":"strict"// Only specified vars available
- Cache hit: `cache hit, replaying logs (no errors) <hash>`
### `longerSignatureKey`
Enforce a minimum key length of 32 bytes for `TURBO_REMOTE_CACHE_SIGNATURE_KEY` when `remoteCache.signature` is enabled. Short keys weaken HMAC-SHA256 signatures. Fails the run immediately if the key is too short.
### `globalConfiguration`
Moves global configuration keys under a top-level `global` key for clarity and changes how `global.inputs` (formerly `globalDependencies`) affects task hashing.
When enabled:
- Global config keys move under `global` with cleaner names
-`global.inputs` files are **prepended to every task's inputs** instead of being folded into the global hash — tasks can opt out of specific global inputs using negation globs
| `ui`, `envMode`, `cacheDir`, `daemon`, `concurrency`, `noUpdateNotifier`, `dangerouslyDisablePackageManagerCheck`, `remoteCache` | Same names under `global` |
**Behavior change for `global.inputs`:**
With `globalDependencies` (old): files are hashed into the **global hash**, which is embedded in every task's cache key. Changing any of these files invalidates all tasks — there is no opt-out.
With `global.inputs` (new): files are treated as **implicit task inputs** prepended to each task's `inputs` globs. This means:
- Tasks can exclude specific global files: `"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]`
- The global hash no longer includes these file hashes (it still includes lockfile, engines, global env, etc.)
- Tasks with no explicit `inputs` still hash all package files plus the global inputs
See the [gotchas doc](./gotchas.md) for guidance on using `$TURBO_DEFAULT$` with `global.inputs`.
## noUpdateNotifier
Disable update notifications when new turbo versions are available.
```json
{
"noUpdateNotifier":true
}
```
## dangerouslyDisablePackageManagerCheck
Bypass the `packageManager` field requirement. Use for incremental migration.
```json
{
"dangerouslyDisablePackageManagerCheck":true
}
```
**Warning**: Unstable lockfiles can cause unpredictable behavior.
## Git Worktree Cache Sharing
When working in Git worktrees, Turborepo automatically shares local cache between the main worktree and linked worktrees.
**How it works:**
- Detects worktree configuration
- Redirects cache to main worktree's `.turbo/cache`
- Works alongside Remote Cache
**Benefits:**
- Cache hits across branches
- Reduced disk usage
- Faster branch switching
**Disabled by**: Setting explicit `cacheDir` in turbo.json.
Root `package.json` scripts for turbo tasks MUST use `turbo run`, not direct commands.
```json
// WRONG - bypasses turbo, no parallelization or caching
{
"scripts":{
"build":"bun build",
"dev":"bun dev"
}
}
// CORRECT - delegates to turbo
{
"scripts":{
"build":"turbo run build",
"dev":"turbo run dev"
}
}
```
**Why this matters:** Running `bun build` or `npm run build` at root bypasses Turborepo entirely - no parallelization, no caching, no dependency graph awareness.
## #2 Using `&&` to Chain Turbo Tasks
Don't use `&&` to chain tasks that turbo should orchestrate.
```json
// WRONG - changeset:publish chains turbo task with non-turbo command
// CORRECT - use turbo run, let turbo handle dependencies
{
"scripts":{
"changeset:publish":"turbo run build && changeset publish"
}
}
```
If the second command (`changeset publish`) depends on build outputs, the turbo task should run through turbo to get caching and parallelization benefits.
## #3 Overly Broad globalDependencies
`globalDependencies` affects hash for ALL tasks in ALL packages. Be specific.
```json
// WRONG - affects all hashes
{
"globalDependencies":["**/.env.*local"]
}
// CORRECT - move to specific tasks that need it
{
"globalDependencies":[".env"],
"tasks":{
"build":{
"inputs":["$TURBO_DEFAULT$",".env*"],
"outputs":["dist/**"]
}
}
}
```
**Why this matters:**`**/.env.*local` matches .env files in ALL packages, causing unnecessary cache invalidation. Instead:
- Use `globalDependencies` only for truly global files (root `.env`)
- Use task-level `inputs` for package-specific .env files with `$TURBO_DEFAULT$` to preserve default behavior
With `futureFlags.globalConfiguration`, this is less of a concern because `global.inputs` acts as implicit task inputs — tasks can opt out of specific files with negation globs. But keeping the list focused is still good practice.
## #4 Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks":{
"build":{
"env":["API_URL","DATABASE_URL"],
"inputs":["$TURBO_DEFAULT$",".env*"]
},
"test":{
"env":["API_URL","DATABASE_URL"],
"inputs":["$TURBO_DEFAULT$",".env*"]
}
}
}
// BETTER - use globalEnv and globalDependencies
{
"globalEnv":["API_URL","DATABASE_URL"],
"globalDependencies":[".env*"],
"tasks":{
"build":{},
"test":{}
}
}
```
**When to use global vs task-level:**
-`globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
## #5 Using `../` to Traverse Out of Package in `inputs`
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
- Package tasks run in **parallel** across all packages
- Each package's output is cached **individually**
- You can **filter** to specific packages: `turbo run test --filter=web`
Root Tasks (`//#taskname`) defeat all these benefits. Only use them for tasks that truly cannot exist in any package (extremely rare).
## #7 Tasks That Need Parallel Execution + Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must still invalidate cache when dependency source code changes. Using `dependsOn: ["^taskname"]` forces sequential execution. Using no dependencies breaks cache invalidation.
**Use Transit Nodes for these tasks:**
```json
// WRONG - forces sequential execution (SLOW)
"my-task":{
"dependsOn":["^my-task"]
}
// ALSO WRONG - no dependency awareness (INCORRECT CACHING)
"my-task":{}
// CORRECT - use Transit Nodes for parallel + correct caching
{
"tasks":{
"transit":{"dependsOn":["^transit"]},
"my-task":{"dependsOn":["transit"]}
}
}
```
**Why Transit Nodes work:**
-`transit` creates dependency relationships without matching any actual script
- Tasks that depend on `transit` gain dependency awareness
- Since `transit` completes instantly (no script), tasks run in parallel
- Cache correctly invalidates when dependency source code changes
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
## Missing outputs for File-Producing Tasks
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG - build produces files but they're not cached
"build":{
"dependsOn":["^build"]
}
// CORRECT - outputs are cached
"build":{
"dependsOn":["^build"],
"outputs":["dist/**"]
}
```
No `outputs` key is fine for stdout-only tasks. For file-producing tasks, missing `outputs` means Turbo has nothing to cache.
## Forgetting ^ in dependsOn
```json
// WRONG - looks for "build" in SAME package (infinite loop or missing)
"build":{
"dependsOn":["build"]
}
// CORRECT - runs dependencies' build first
"build":{
"dependsOn":["^build"]
}
```
The `^` means "in dependency packages", not "in this package".
## Missing persistent on Dev Tasks
```json
// WRONG - dependent tasks hang waiting for dev to "finish"
"dev":{
"cache":false
}
// CORRECT
"dev":{
"cache":false,
"persistent":true
}
```
## Package Config Missing extends
```json
// WRONG - packages/web/turbo.json
{
"tasks":{
"build":{"outputs":[".next/**"]}
}
}
// CORRECT
{
"extends":["//"],
"tasks":{
"build":{"outputs":[".next/**"]}
}
}
```
Without `"extends": ["//"]`, Package Configurations are invalid.
## Root Tasks Need Special Syntax
To run a task defined only in root `package.json`:
// WRONG - only watches test files, ignores source changes
"test":{
"inputs":["tests/**"]
}
// CORRECT - extends defaults, adds test files
"test":{
"inputs":["$TURBO_DEFAULT$","tests/**"]
}
```
Without `$TURBO_DEFAULT$`, you replace all default file watching.
## Excluding `global.inputs` Without `$TURBO_DEFAULT$`
When using `futureFlags.globalConfiguration`, `global.inputs` values are prepended to every task's inputs. If you want to exclude a global input from a specific task, you **must** include `$TURBO_DEFAULT$` to preserve default file hashing.
```json
// WRONG - task hashes NO files at all (global input cancelled, no defaults)
"build":{
"inputs":["!$TURBO_ROOT$/config.txt"]
}
// CORRECT - task hashes all package files, minus config.txt
Without `$TURBO_DEFAULT$`, the only inclusion glob comes from `global.inputs`, which the negation cancels out. The task ends up with no inclusions and no default file hashing, so it hashes nothing. Changes to source files won't cause cache misses.
## Caching Tasks with Side Effects
```json
// WRONG - deploy might be skipped on cache hit
"deploy":{
"dependsOn":["build"]
}
// CORRECT
"deploy":{
"dependsOn":["build"],
"cache":false
}
```
Always disable cache for deploy, publish, or mutation tasks.
| `$TURBO_DEFAULT$` | Include default inputs, then add/remove |
| `$TURBO_ROOT$/<path>` | Reference files from repo root |
```json
{
"tasks":{
"build":{
"inputs":[
"$TURBO_DEFAULT$",
"!README.md",
"$TURBO_ROOT$/tsconfig.base.json"
]
}
}
}
```
### Interaction with `global.inputs`
When `futureFlags.globalConfiguration` is enabled, files listed in `global.inputs` are prepended to every task's `inputs`. The combined list is then used to compute the task hash.
This is different from `globalDependencies`, where files were hashed into the **global** hash and could not be influenced by task-level `inputs`.
**With `globalDependencies` (old behavior):**
-`globalDependencies` files contribute to the global hash
- Task `inputs` only control which **package** files are hashed
- There is no way for a task to "opt out" of a `globalDependencies` file
**With `global.inputs` (new behavior):**
-`global.inputs` files are merged into each task's `inputs` globs
- Task `inputs` and `global.inputs` are combined, then the full list is hashed into the **task** hash
- Tasks can exclude specific global files with negation globs
### `globalPassThroughEnv` - Global Runtime Variables
Same as `passThroughEnv` but for all tasks.
```json
{
"globalPassThroughEnv":["GITHUB_TOKEN"]
}
```
## Wildcards and Negation
### Wildcards
Match multiple variables with `*`:
```json
{
"env":["MY_API_*","FEATURE_FLAG_*"]
}
```
This matches `MY_API_URL`, `MY_API_KEY`, `FEATURE_FLAG_DARK_MODE`, etc.
### Negation
Exclude variables (useful with framework inference):
```json
{
"env":["!NEXT_PUBLIC_ANALYTICS_ID"]
}
```
## With `futureFlags.globalConfiguration`
When the `globalConfiguration` future flag is enabled, global environment keys move under the `global` key with cleaner names:
| Old (top-level) | New (`global.`) |
| ---------------------- | ---------------- |
| `globalEnv` | `env` |
| `globalPassThroughEnv` | `passThroughEnv` |
`global.env` and `global.passThroughEnv` behave identically to their top-level counterparts — they affect the global hash and all tasks, respectively. The rename is purely organizational.
- Hashes DATABASE*URL and NEXT_PUBLIC*\* vars (except analytics)
- Passes through SENTRY_AUTH_TOKEN without hashing
- Includes all .env file variants in the hash
- Makes CI tokens available globally
### With `futureFlags.globalConfiguration`
The same config using the `global` key. The `.env` files move to `global.inputs`, which means they get folded into each task's hash individually rather than the global hash. This lets tasks exclude specific `.env` files if needed.
This wouldn't have been possible with `globalDependencies`, where `.env.production` would be baked into the global hash and affect every task unconditionally.
description:Please describe the question you have as clear and concise as possible. Include error messages, unexpected behavior, or steps to reproduce the problem.
validations:
required:true
- type:dropdown
attributes:
label:Langfuse Cloud or Self-Hosted?
options:
- "Langfuse Cloud"
- "Self-Hosted"
validations:
required:true
- type:input
attributes:
label:If Self-Hosted
description:What version are you running? We may ask you to upgrade to the latest version, as many issues are continuously being fixed.
- type:input
attributes:
label:If Langfuse Cloud
description:Please share the link to your Langfuse project or the specific view you have a question about. This helps us resolve requests faster.
- type:textarea
attributes:
label:SDK and integration versions
description:If you're experiencing an issue with an integration or SDK, please share all package versions you're using. If you are not on the latest version, try upgrading, as this will often resolve the issue.
- type:checkboxes
attributes:
label:Pre-Submission Checklist
description:Please check for existing [issues](https://github.com/langfuse/langfuse/issues) and [discussions](https://github.com/orgs/langfuse/discussions) and ask the [Langfuse AI chatbot](https://langfuse.com/docs/ask-ai).
options:
- label:I have checked for existing issues/discussions and consulted Langfuse AI.
description:A clear and concise description of the bug, and what you expected to happen when you encountered it.
validations:
required:true
- type:textarea
attributes:
label:Steps to reproduce
description:Describe how to reproduce the bug. Please provide detailed steps, code snippets, a minimal reproduction repository, etc.
validations:
required:true
- type:dropdown
attributes:
label:Langfuse Cloud or self-hosted?
options:
- "Langfuse Cloud"
- "Self-hosted"
validations:
required:true
- type:input
attributes:
label:If self-hosted, what version are you running?
description:We may ask you to upgrade to the latest version, as many issues are continuously being fixed.
- type:textarea
attributes:
label:SDK and integration versions
description:If you're experiencing an issue with an integration or SDK, please share all package versions you're using. If you are not on the latest version, try upgrading, as this will often resolve the issue.
- type:textarea
attributes:
label:Additional information
description:Add any other information related to the bug here, including screenshots if applicable.
- type:dropdown
id:contribute
attributes:
label:Are you interested in contributing a fix for this bug?
description:If this is a confirmed bug, the maintainers are happy to provide guidance and review.
about:If you can’t get something to work the way you expect, open a question in our discussion forums.
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.