The 8.x dependency convergence was already done and building. What was left is
the half it cannot catch: @hanzo/gui accepts any prop and drops the ones it does
not recognise, so a gui 7 spelling type-checks, builds, ships, and does nothing.
Asked the renderer instead of the type-checker (scripts/gui-prop-probe.mjs
renders a prop and reads the host element and emitted class back out), which
settled the open `tag` vs `render` question the interrupted session left and
found four live defects:
tag="a" -> <div tag="a"> an inert link
style lineHeight: 1.1 -> line-height: 1.1px a ratio is not a length
- CloudflareModule's pages.dev/workers.dev URL chip and every ContactModule
channel card (mailto included) were <div>s. Both now render="a".
- PublicLanding's hero title, PitchHero's headline and the CodeSamples block
shipped line-height 1.1px/1.12px/1.6px — a wrapped title, and every line of a
code sample, drawn on one baseline. gui appends px to a bare number in `style`
as much as in a prop, so the ratio is now spelled as a string.
Because the type system provably cannot gate this class, the gate is the source
text: src/lib/gui8-props.ts holds the four verified rules and its suite runs them
over every file that imports gui (335 of them). Scoping by import is what keeps
it precise — a `{ tag: 'v1' }` image tag in a pure-logic module is out of scope
by construction, as are the rule module and its own fixtures.
Also: typescript stays on 5.x, and that is correct rather than a shortfall.
typescript@7.0.2 is genuinely the native Go compiler (its tsc is a statically
linked ELF from typescript-go/cmd/tsgo), but it ships only
{version, versionMajorMinor} on the main entry, while `next build` calls
ts.parseJsonConfigFileContent / ts.JsxEmit / ts.ModuleKind /
ts.ModuleResolutionKind — all undefined there. TS7 breaks Next exactly the way it
breaks tsup, for the same reason. @typescript/native-preview (7.0.0-dev, behind
stable) stays removed.
Verified: next build ✓ compiled successfully in 25.8s, 20/20 pages;
tsc --noEmit exit 0; vitest 2903 passed / 8 skipped (the pre-existing enso-bench
parity self-skip); build:embed ✓ static export ready, 30 handlers restored.
Zero tailwind/radix/shadcn, not even transitively.
(cherry picked from commit c9a4d92554fa8b8274801218a2c14a0044cf17bf)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 19e6d2e250b7c4ad01a19b58c66fa087e125c447)
(cherry picked from commit bac7cc5bf68eb03973ab0bec5d28fcc23b2a2ee8)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit e077a2285a3c1c682332f83260ac57a9cf1010d5)
app/design/ carried a verbatim vendor of @hanzo/design's tokens, synced by hand
on 2026-07-24 because the package wasn't on npm. It is now (0.4.6), and the
vendored copy had already drifted a full border rework behind it — pure-black
ground, solid #1f1f1f borders, grey destructive, the pre-0.4.x palette the rest
of the fleet just moved off of. index.css now imports the real dependency and
the seven vendored token files are deleted; the two console-only tokens they
added (--border-card, --border-hairline) were unused. The Tamagui theme layer
in globals.css derives --colorN from the neutral ladder, which is unchanged, so
only the semantic surfaces adopt the rework — the intended unification.
tsc 0 errors, 3157 tests pass, next build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The assistant's way in was the only shape of it this module did not own: two
small buttons in the topbar, wedged between the search box and the account
chrome, putting it in a third place and squeezing a 390px header to five
controls. Both move into one floating control bottom-right — the same
openChat/startVoice, the same surface, in the corner it actually appears in.
All products was a directory you could not walk. Each app rendered as a plain
DIV with role=null and cursor:auto — measured, not read — so the one place the
whole catalog is browsable had exactly one live control per row, the pin. The
row opens its app now, through the shared openProduct, and closes the pane
behind it; pin stays a separate control that stops the press from bubbling.
And a pin made after sign-in was thrown away on the next reload. Preferences
are read off the identity token's claims — a snapshot taken when that token was
minted — and once a user has saved anything the token CARRIES one, so the merge
let an hour-old snapshot beat a newer write. It is now told the ordering it was
missing: the token's own iat against a stamp written only when the server
acknowledges a save. A save that never landed earns nothing, so this orders two
real writes rather than inventing durability in localStorage. There is still no
GET for the document; the smallest seam is named in preferences-core.
Five white-filled buttons competed on the home, counted by computed background
luminance. Now one: the getting-started card's active step.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The `ml-pipelines` product (label "ML Pipelines", description "Orchestrated
training and evaluation pipelines (Kubeflow)", status enabled, slug aliases
`/kubeflow` and `/mlpipelines`) read nothing but Katib and Trainer: its three
sources were GET /v1/train/health, GET /v1/train/experiments (its "Pipelines")
and GET /v1/train/jobs (its "Runs"). Those CRDs are not served by the cluster and
cloud deleted the ops, so every one of them is now a 404 and the product's only
possible state is an error card. `KubeflowApi` had no other consumer.
There is nothing to repoint it at. Per-org model-shape SEARCH — the job Katib
was installed for — is /v1/risk/search, which runs natively in the org's own
sandbox and needs no CRD.
Also removed from the `/training` BFF allow-list: `train/jobs`,
`train/experiments`, `train/health`. An allow-list entry is a declaration that a
path exists; those three no longer do. `ml/models` (kserve, live and serving) and
the eight `finetune/*` broker heads stay exactly as they were.
STILL WIRED TO THE DELETED PATHS, deliberately left for its own change:
FinetuningModule's Jobs tab, its loss chart and NewTrainingPanel still call
TrainApi.listJobs/createJob/experiments. Gutting them would delete a product;
the right fix is to repoint them at the /v1/finetune/* broker — already
allow-listed two lines below in the same proxy, and a richer surface (presets, HF
pickers, cancel, deploy-to-serving). That is a payload-contract change with its
own verification, not a line to slip into a deletion.
Verified: match-core 37/37; the full suite is 3175/3175 on this tree, which is
pristine origin/main's 3177 minus exactly the two retired alias assertions, with
the same single pre-existing social.test.ts collection failure. tsc reports SIX
FEWER error files than pristine origin/main (413 vs 419 — the local
@hanzo/gui shorthand drift this box has either way) and not one error in a file
this change touches.
The console spoke of three key prefixes. IAM resolves two: sk- (secret,
same-tenant pinned) and pk- (publishable, refused at the auth door). An
hk- string is not a key, so nothing in a user-facing surface may offer it.
The one behavioural change is the workbench Inspector's prefix filter,
which routed hk-/sk-/pk- to the account key status; it now recognizes
sk-/pk- and refuses anything else, with the error text to match. The
pasted value is never sent — the Inspector reads the session's own key
status — so this narrows what the UI calls a key without touching auth.
The rest is copy: placeholders, Bearer examples, product Auth facts and
the guide step now name sk-, the credential the /keys route actually
mints. The workbench legend had sk- as a "provider key" and implied all
three ride the Authorization header; it now separates the secret that
authenticates from the publishable value that never does.
Commerce dropped the compound prefixes from its billing routes. The /v1/billing/
namespace already says "billing", so billing/payment-methods stuttered. Both
servers now register only the short names, and the live edge agrees:
/v1/billing/methods 401, /v1/billing/settings 403, /v1/billing/alerts 403, while
payment-methods, payment-config and spend-alerts are all 404.
The console never followed. Its card list, its card save, its card detach and its
Square-config read were all pointed at routes that no longer exist, which means a
new user could not add a card. This was the revenue path, broken in production.
Alerts had already been repointed, so the four dead call sites were the three
payment-methods ones and payment-config; they now build methods and settings.
No alias, no fallback — one name per concept.
The tests were part of the defect rather than the guard against it. Every suite
around payment methods stubbed a response body and asserted the normalization, so
a client aimed at a 404 stayed green; that is precisely how this survived. The URL
is now pinned where the request is made, including the two reads nothing had ever
asserted, and reverting any short name turns the suite red — checked, not assumed.
The two e2e specs that pinned dead URLs are corrected, and the isolation spec also
had the retired /billing/v1/ prefix.
POST /v1/billing/me/welcome is deleted rather than repointed, along with the type
and the module that fed it. Commerce removed that route deliberately: it was a
self-service mint, a browser could grant its own org $5, and commerce's own
api/billing/mint_gates_test.go calls it the TOCTOU double-mint. Credit is minted
only through the mint-gated POST /v1/billing/credit. The call was already failing
silently, so restoring it would have re-opened a closed money hole in exchange for
nothing. The trial credit still arrives — commerce grants it server-side when a
card is vaulted, and signup grants it server-side — and that path is untouched.
Scope was measured, not guessed. /v1/finance/payment-methods is still alive and
/v1/finance/methods is 404, so the finance ledger keeps the compound name; a
blanket repo-wide rename would have broken it. The Billing Center tab slugs are
console page URLs, not server routes, and are unchanged.
Two headlines blamed the wrong layer. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both
fire when the organization has no Square applicationId or locationId — per-org
configuration, not a property of the deployment. Both now name the organization,
as does the onboarding step that had the same defect, and the stale endpoint hints
beneath them now read settings.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
cloud renamed /v1/admin/spend-caps[/:id] to /v1/admin/caps[/:id] and
/v1/admin/block-storage to /v1/admin/volumes — under /v1/admin there is one
kind of cap, and what the storage board returns is a list of volumes.
TWO literal allowlists carry these names and both are load-bearing, so this
cannot land after cloud without breaking the admin boards:
- ADMIN_AGGREGATE_HEADS (src/lib/server/admin-aggregate.ts) — allowAdminSurface
admits `v1/admin/<head>[/...]`, so the head is what lets the :id sub-path
through at all.
- ADMIN_V1_HEADS (next.config.mjs) — the dev rewrite onto a real backend.
lib/api/admin-spend-caps.ts -> admin-caps.ts, with AdminSpendCapsApi ->
AdminCapsApi and the AdminSpendCap type -> AdminCap, so the module, the API
object, the type and the route all say one thing; its test moves with it.
storage-fleet.ts calls the volumes read. UsageCapsPromoModule.tsx, client.ts,
registry.tsx and the aggregate route's doc comments name these addresses in
prose and would otherwise document routes nobody serves.
e2e/storage-fleet.spec.ts intercepts the cloud call by URL, so its matcher moves
too or the fixture never binds and the board renders empty.
Every hit was a comment naming the wrong system. hanzoai/iam is Hanzo IAM —
original, clean-room work; the vendor-derived server was hanzoai/iam-v1, which
is retired and ships in nothing. Replaced the name with IAM and kept each
comment's fact intact: tokens really are ~3.6 KB full-user JWTs (hence the two
cookies and the chunking), IAM really does pack the full user object, and it
really does skip the client-secret check when the secret is empty.
Comments and markdown only — no identifier, cookie name, or wire field moved.
Cloud's KMS surface is /v1/kms/secrets now — URL-addressed orgs were removed
because a path that names a tenant is caller-selectable. The proxy keeps its
policy predicate exactly (brand org, or the SuperAdmin's ?org= switch) and
expresses the result where every other subsystem already reads it: X-Org-Id,
the acted-on org the identity boundary mints for a switched-in SuperAdmin.
The module footer stops advertising a route that no longer exists.
generateMetadata already resolves the title from the request host — a
Lux console renders <title>Lux Cloud Console</title>. The description
beside it was a literal and shipped 'Unified admin console for Hanzo
Cloud and all cloud products.' to console.lux.cloud and
console.zoo.cloud.
The correct title is exactly why nobody caught it: the visible tab looked
white-labeled, so the head element behind it was never read.
Both strings now come from brandName. Every brand-visible value in this
function must; a literal here re-opens the leak.
Every node row on this route was silently 'not-reporting': luxd serves one
HTTP prefix and it is /v1. Measured 2026-07-27 against api.lux.network:
/ext/info -> 404 /v1/info -> 200 {"version":"luxd/1.36.2",...}
/ext/bc/P -> 404 /v1/bc/P -> 200
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shell had three type scales, thirteen radius spellings, an odd-pixel
spacing ramp, and a stacking order expressed as literals up to 100002. This
makes each of those exactly one thing, and adds the gate that keeps them one.
THE ONE SCALE (gui.config.ts). app/design/typography.css declared the compact
register (11/13/14/15/17/21/26); the Tamagui $N ladder is what thousands of
call sites actually type; ten distinct sizes rendered. The ladder is precisely
why we do not edit the call sites — it is remapped once onto the declared
numbers, so every surface lands on the scale. Same for radius (four values:
6 control, 8 input/row, 12 panel, pill — the three spellings of "pill"
collapse to the $10 token) and spacing (the 4px ramp; $2/$3/$4 were landing on
7/13/18px, the three most-rendered paddings in the app).
The 16px leak was not the ladder: `body` never set a font-size, so everything
the ladder does not reach inherited the browser's 16px root. One declaration
in globals.css, sourced from --text-base, and the inherited size and the named
size finally agree.
ZERO ALL-CAPS. The hard rule. 26 `textTransform="uppercase"` sites deleted
across 15 files and 19 typed-in-caps strings re-cased — including GET STARTED,
which the audit missed and the gate caught. Where a label was carrying
hierarchy by shouting it gets it back the calm way: 11px, weight 500, muted.
Genuine acronyms (API, GPU, CIDR, …) are untouched and allow-listed.
THE Z LADDER (src/lib/z.ts). app/design/z.css has always declared it and was
read in zero places. Every literal now names a role instead: dropdown, modal,
popover, toast. Correcting the brief — @hanzo/brand 1.4.0 ships no --z-* at
all; the vendored z.css is the real ladder, so its numbers are used rather
than a third set invented to solve a problem about having too many sets.
PANEL + ROW (ui/Panel.tsx). The one primitive genuinely missing: a stack of
rounded panels whose rows are label + description left, control right. The
shared per-product Settings view is converted to it, which is every product's
Settings tab, and deletes a bespoke row in the process.
THE GATE (e2e/design-invariants.spec.ts). Asserts on computed style and
geometry, not source: zero uppercase and zero typed caps, membership in each
scale, every stacking layer from the ladder, overlays that actually paint and
sit on screen, WCAG contrast from the colours that painted, and no sideways
scroll at 1440 or 390. Two exemptions, both narrow and declared at the source:
a monogram scales with its circle (data-monogram) and chart axis text lives in
SVG. A rule that only lives in a review comes back.
Pin, sort, filter, search and act, as ONE mechanism each instead of three
duplicates and a broken write path. Every claim measured in a browser on
computed style and geometry (e2e/find-and-do.spec.ts, 7 tests).
BUG: every preference was lost on reload. Preferences treated the account as
authoritative for keys it had never mentioned, so each load replaced state AND
the write-through cache with the token's (empty) view — losing pins, pin groups,
product colours and open nav sections. The account now wins per key it CARRIES;
the cache fills the rest (preferences-core.mergePrefs, pure, tested).
ONE list view (src/lib/list): useList(id) persists a list's search, order and
facets under `list.<id>` in the same account store as pins. Its comparator,
reducer and predicate are promoted verbatim out of admin/infra's private copy,
which now re-exports them — one implementation, its 30 tests unchanged. `Filters`
is the one bar (search + facets + a Reset that exists only when something is
narrowed). Adopted by Models and Marketplace, dropping two bespoke search boxes.
Pins in search: pinnedFirst is the one "pinned leads" rule, shared by the sidebar
and the palette. Every result carries a right-edge pin — invisible until reached,
lit while pinned — and ⌥↵ pins the selection without closing.
BUG (introduced, then caught): pins must not outrank what you typed — floating
them over the ranked list made "billing" + ↵ open Models. Pins order the DEFAULT
view only; typing is decided by relevance. Locked by asserting where you land.
BUG: the resting pin painted at full strength — a plain .hz-pin lost to Gui's
compiled `:root ._ops-…` (0,2,0), then a broken CSS comment silently killed the
rule outright. Only the computed-style assertion caught either.
tsc clean; vitest 3121 passed (+35); find-and-do 7/7 with screenshots, incl.
4.5:1 contrast and zero horizontal body scroll at 390px. NOT verified on live
admin.hanzo.ai (auth-gated, no password typed).
Two rendering contracts were silently not applying. Both found by measuring
computed styles in a real browser, not by reading code.
The product-guide headline had a 1px line box. PitchHero set
`style={{ lineHeight: 1.12 }}` — a correct ratio in plain React, whose unitless
allow-list includes lineHeight. React Native Web's does not, so under @hanzo/gui
it compiled to `line-height: 1.12px`: a 30px/900 headline in a 1px box, a 29px
overflow that dropped its descenders into the subhead and clipped the GET STARTED
eyebrow. It now wears `hz-display`, the class this app already added for exactly
this (PublicLanding, v8.5.24) — one way, one rule, every token and breakpoint.
Measured after: 30px on 33px leading at desktop, clean two-line wrap at 390px.
e2e/leading.spec.ts pins the invariant rather than the call site: no visible text
node on /models, /agents or /playground may compute a line-height smaller than its
own font-size. It fails on the unfixed tree and catches the next numeric lineHeight
anyone writes without their knowing about RNW's allow-list.
No overlay was wearing the elevation ladder. Gui compiles its shadow props to an
atomic rule injected at runtime as `:root ._bxsh-…` — specificity (0,2,0). The
design-token utilities were plain `.hz-paper` (0,1,0) and lost, so the command
palette, app launcher, floating chat and three menus rendered Gui's
`0 12px 24px rgba(0,0,0,.33)` instead of ring + top highlight + --hz-elevation-3.
On the true-black canvas that shadow is nearly invisible — the sheets did not lift
off the page. The utilities are now `:root .hz-x.hz-x` (0,3,0): deterministic in
either stylesheet order, no !important.
And every anchored overlay now wears ONE surface. Eleven Popover.Content sites
passed Gui's `elevate` while three wore `hz-paper` — one concept, two depths, plus
the same bordered/bg/borderColor triple repeated fourteen times. All fourteen now
spread ~/components/ui/paper, which holds the surface, the token elevation and the
opacity-only hz-menu-in entrance in one place.
Verified by rendering: scope switcher, network picker, model selector, save-prompt
popover and the ⌘K palette all opaque, correctly anchored, ring visible, nothing
occluded. 3,086 unit tests pass; leading spec green.
Three defects found by a rendered-DOM audit (CDP + hit-testing) of the live
cloud.hanzo.ai at 390x844 and 1440x900. Measured before AND after, per defect.
FOOTER legal links were CLIPPED off-screen at 390px. The link clusters are Views
(`flex-shrink: 0`), so they held max-content width and their own `flex-wrap` never
engaged: Terms painted at x 397->435 on a 390px viewport, while
`html,body{overflow-x:clip}` keeps `documentElement.scrollWidth` at 390 — the
overflow is CLIPPED, not scrollable, so a legally-required link could not be
reached by any gesture. `ConsoleFooter`'s `flexShrink` already fixes that in this
line; production is BEHIND it (live still renders the hero as a SPAN, so it
predates the same commit), so rather than re-fix it this locks the geometry: at
390 the row wraps to two lines, Terms lands at x 149->187, every link hit-tests
to itself, and nothing on the page is painted past the right edge.
HEADER chrome rendered in a SYSTEM font while the body rendered Geist.
`@hanzogui/shell` sets its own stack as an INLINE style on its root
(`fontFamily: CHROME.font` = `ui-sans-serif, system-ui, -apple-system, "Segoe UI",
…`, which names no Geist) and its subtree inherits it — its buttons re-declare
`font-family: inherit`. Live: wordmark `Noto Sans:11:SYSTEM`, nav
`Noto Sans:9:SYSTEM`, hero `Geist:26:custom` — mixed typography on one screen.
Geist loads fine (self-hosted woff2), so this is a CASCADE problem and the font
loading is untouched. One rule in globals.css pins `[data-hanzo-shell]` and its
descendants to `var(--font-sans)`; `!important` is required because nothing else
beats an inline declaration, and `code/pre/kbd/samp` keep the mono face so the two
font invariants stay orthogonal. After: nav `Geist:9:custom`, Meet-Hanzo
`Geist:10:custom`, CTA `Geist:7:custom` — the body's own face. Deleting ONLY that
rule from the CSSOM on the same build reverts the header to `Noto Sans:9:SYSTEM`
with the old stack, so the rule is demonstrably the fix, in isolation.
The desktop logged-out header carried TWO "Sign in" affordances. `HanzoHeader`
renders its OWN account link whenever `account` is nullish
(`account ?? <DefaultAccount/>`) and `landingSurface` already relabels the primary
CTA "Sign in", so live read `[Get API key] [Sign in -> /signin] [Sign in ->
href="#"]` — the duplicate was also a dead link. `PublicLanding` now declines the
control explicitly (`account={false}`: not nullish, so the default never renders,
and React draws nothing — including the mobile sheet's identity row).
Also: `@hanzogui/shell` was pinned `^7.6.4`, which is not published (latest is
7.6.3) — `npm install`, which the Dockerfile runs, fails ETARGET on it, so no
image could build. Relaxed to `^7.6.3`, which still admits 7.6.4 the moment it
publishes; this tree compiles and passes against 7.6.3.
Verification: `next build` ✓ ("Compiled successfully", types + 20/20 static
pages); `tsc --noEmit` clean; `vitest` 3024/3024; `e2e/landing-chrome` 3/3 against
the PRODUCTION build on `next start`. Font evidence is CDP
`CSS.getPlatformFontsForNode` (real family + custom-vs-system) — never
`document.fonts.check()`, which answers true on a page with zero @font-face rules.
The console's `/paas` BFF forwarded to `platform.hanzo.ai/v1/paas/*`. That host
has no such route: the platform Next app's own surface is /v1/apps, /v1/runner,
/v1/git-webhook … and it answers 401 uniformly for EVERY /v1/* path, nonsense
included — so the failure was invisible and the PaaS board could never load.
The PaaS control plane is `/v1/paas/*` on the unified backend:
api.hanzo.ai/v1/paas/health 200 {"crd":true,"k8s":true,"service":"paas"}
api.hanzo.ai/v1/paas/apps 403 (auth required)
api.hanzo.ai/v1/paas/zzz 404 <- a real routing table
platform.hanzo.ai/v1/paas/health 401
platform.hanzo.ai/v1/zzz-nonsense 401 <- auth-first catch-all
So the proxy now reads `CLOUD_API_URL` — the SAME server-side base every other
console BFF route already uses (in-cluster in prod, api.hanzo.ai everywhere
else). It no longer reads PLATFORM_URL, so the stale deploy-time override
pointing at platform.hanzo.ai is inert rather than silently authoritative.
Also repointed the remaining per-service API hosts to the ONE endpoint:
- resource `provisionSnippet` emitted `curl -X POST cloud.hanzo.ai/v1/<kind>`;
a copied snippet now targets api.hanzo.ai (verified routed: /v1/kv → 403
"X-Org-Id required").
- SearchModule's SSR origin fallback, .env.example, README, and the api/train/
tenants/platform-apps doc comments (`api.cloud.hanzo.ai`, `platform.hanzo.ai/v1/*`).
NEXT_PUBLIC_PLATFORM_URL stays: it is a FRONTEND deep-link host, not an API base.
Verified: pnpm typecheck clean; vitest 242 files / 3013 tests pass.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Every rule in the app asks for Geist. On live console.hanzo.ai `document.fonts.size`
was **0**: the two `@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/…')` were
refused by the browser as cross-origin (ERR_BLOCKED_BY_ORB), so every customer read
the entire product in system-ui. The import ORDER was already fixed once for this
same symptom — the remaining cause was the CDN itself, which is a dependency we do
not control sitting on our own critical render path.
Now served from public/fonts. One VARIABLE file per family spans weights 100-900, so
eighteen static cuts collapse to two requests (56K + 58K) and any weight the design
reaches for already exists — no second place to add a face.
Verified on the real `build:embed` export, not asserted: fonts 0 -> 2,
"Geist 100 900: loaded", document.fonts.check('16px Geist') true, woff2 served 200.
(Geist Mono reports unloaded on the landing page because nothing there sets mono —
correct lazy behaviour.)
LIVE BUG, found by rendering the production landing at 390px: the hero headline
wraps to two lines on a phone and the lines OVERPRINTED each other — the front
door of console.hanzo.ai, unreadable on mobile. Desktop was fine (one line), which
is exactly why it survived: it is invisible until the text wraps.
Cause: a Gui font-size token ships a line-height tuned for ONE line. Nothing set a
line-height for the wrapped case, so the line boxes collided.
The fix must live in CSS, not a style prop. React Native Web reads a bare numeric
`lineHeight` in a style object as PIXELS — I tried `lineHeight: 1.1` there first
and it made the crush WORSE (1.1px leading), which is the tell. Unitless in real
CSS is relative to the element's own font-size, so ONE rule holds at $11 and $13
and every breakpoint: `.hz-display` in globals.css, worn via className (which
forwards to the DOM node on web) — the same mechanism as .hz-mono/.hz-tnum.
Verified by RENDERING, not by reading: 390/768/1280/1680 all pass with no
horizontal overflow, and the mobile screenshot shows 'The AI cloud, / one platform'
cleanly on two lines. tsc 0 errors; build:embed green. → v8.5.24
Third and final pass. The chrome now expresses state by WEIGHT, ICON and LABEL;
hue is reserved for the one thing it legitimately carries — a third party's own
identity.
ONE MODULE (job 1). `tone-var.ts` was a second place to look for one idea, so
`toneVar` folds into `tone.ts` beside the ladder it is derived from — change the
emphasis order once and both the `$colorN` token and the `var(--colorN)` CSS form
move together. 37 importers repointed; `tone.ts` stays pure and node-testable.
`statusVar` had zero callers and is deleted rather than carried.
ONE CATEGORICAL SCALE. `tone` answers "what does this STATE mean"; a donut slice,
a graph node kind and a funding class ask a different question — "how do I tell N
categories apart". That scale already existed as `Charts.CHART_PALETTE` but lived
inside a React component, so `provider-billing.ts` had hand-copied four of its
steps and `graph-logic.ts` had invented a hue ladder of its own. Extracted to the
pure `lib/theme/ramp` (`RAMP`/`OTHER`) and shared: one scale, no copies, and a
canvas or SVG mark can reach it without importing React.
THE REMAINING HUES (job 2). ~30 chromatic hexes plus the rgba() and vendor-colour
duplicates the hex grep missed, across 91 files: greens → positive, reds →
critical, ambers → warning, blues/cyans → neutral, slate → muted. Every local
ladder is deleted and repointed (agents STATUS_HEX, machines STATUS_HEX, code
TIER_HEX, railway GREEN/RED, knowledge NODE/EDGE_COLORS, growth STAGE_META). Every
icon and label is kept — in monochrome they are what carries the meaning, which is
also why the Cloudflare orange could go: a Cloud glyph beside the word "Cloudflare"
was never relying on the hue.
Two fixes fall out of the sweep rather than being bolted on: a white glyph on a
user-chosen accent now asks `contrastText` instead of assuming white, and the
select chevron — byte-identical in two files — becomes one asset.
VENDOR IDENTITY PRESERVED. `brand-marks`/`brand`/`ProviderLogo` are untouched:
Anthropic coral, Qwen violet, NVIDIA green are those vendors' marks, not our
chrome. `ai-accounts` had re-declared two of them by hand and drifted (its OpenAI
green vs the brand map's black), so it now RESOLVES through the same
`brandForModel` the avatars use — identity kept, the copy gone.
Colour is never compared to decide identity: `positive` and `warning` share a
token deliberately. The graph test now asserts the property that actually holds —
distinct steps on a zero-saturation scale — instead of pinning an indigo hex.
Residual `#rrggbb` in src/ is 248 → 101, and 74 of those are the vendor brand maps
and their tests. What is left is a data: URI where var() cannot resolve, a
cross-origin payment iframe that cannot read our tokens, hex-input placeholders,
prose, and the two greyscale scales themselves.
typecheck 0 errors · vitest 3013 passed / 8 skipped · build:embed green
platform.hanzo.ai now lands on a REAL deploy platform, not the generic catalog:
- App Store product (store, Platform): browses the LIVE 1000+-app templates.hanzo.ai
catalog fetched straight from the browser (open CORS, no BFF -> works in go:embed);
search-first + Load-more (DOM capped), monogram logo fallback.
- One-click deploy reuses the console's REAL PaaS path (PaasApi -> /v1/platform/*):
ensure project -> createApp{source:git} -> deploy; honest build/live states. No new
backend.
- Maker Earn-20% hook -> in-console /authors (parsed from links.github).
- PlatformHome: deploy hero + tiles (App Store/Containers/Functions/Usage) + featured
OSS strip + your projects; PlatformModule '' renders it.
- Home Deploy-OSS tile -> native /store (was an external link-out).
- Additive only: the committed single-product platform shell is untouched.
tsc clean; vitest +24 green; next build + build:embed green; render-proven
(e2e/platform-store.spec.ts).
Wire the Hanzo Design System (hanzoai/design) into the console as the styling
source of truth and convert the whole surface to true-black MONOCHROME
(Linear/Vercel-grade), keeping only the genuine semantic hues (green live/success ·
amber caution · red error).
- Vendor the design token layer (color/type/space/radius/elevation/motion/z) into
app/design/ — @hanzo/design is unpublished, and its README contract is copy-1:1 —
and import it before globals.css so the Tamagui theme derives from the design
neutral ladder.
- colors.ts: retire the per-product/category rainbow. Product + category icons now
read ONE neutral (design --neutral-300); legacy chromatic prefs still resolve but
only to greyscale. Public API + persisted keys unchanged.
- Charts CHART_PALETTE → monochrome descending-lightness ramp (was a purple-led
rainbow); grid/axis neutralized.
- Primary actions (Deploy Endpoint / landing CTA) → theme-aware white-on-black
monochrome (was purple #7c5cff).
- Purge #a371f7 / #c084fc / #3aa0ff / #7c5cff / #8b5cf6 / #5E6AD2 house accents
across 30 modules → design neutrals; router / mission-control / scope / hero /
railway / crm accents monochromized; hero + template-tile gradients → neutral.
- Tests enforce the monochrome guarantee (every swatch greyscale; legacy keys never
reintroduce a hue). 2953 unit tests green, tsc clean, next build ok.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Two first-class primary-action tiles on the post-signin dashboard home
(app/(dashboard)/page.tsx), rendered through ONE new presentational,
prop-driven PrimaryActionTile primitive — the bespoke GetApiKeyCta card is
folded into it, so the three top actions share one card definition (DRY).
- Deploy OSS -> opens the one-click OSS template catalog in a new tab
(config.templatesUrl, default platform.hanzo.ai/templates, override
NEXT_PUBLIC_TEMPLATES_URL). No in-console fetch: the 1000+-app catalog
never touches the dashboard's first paint; it loads only on press.
- Earn from your OSS -> the EXISTING /authors OSS revenue-share program
(reused, not duplicated): 20% of the compute margin your project drives,
paid to your Hanzo wallet (SidebarWallet balance). Honest copy, no
fabricated amounts.
Reuses the shared ProductIcon + product-color system (colorOf) for the icon
tiles; adds shared, env-overridable config.templatesUrl.
Verify: tsc 0 new errors; vitest 2948 pass; ~0.5KB gzipped on /, no new deps.
- PublicLanding hero: drop the unitless style lineHeight:1.1 — on a Tamagui/RNW
<Text> a bare number is coerced to 1.1px, collapsing the 60px headline onto a
1px line so the two wrap-lines overlapped into garbled text. Same trap fixed in
CodeSamples (1.6 -> '1.6em').
- Geist fonts: the CDN @import lived in globals.css and was emitted AFTER the
reset rules in the compiled bundle, so per spec it was invalid and dropped ->
the app fell back to system-ui. Moved the two @imports to app/fonts.css imported
FIRST in layout, so they lead the bundle and Geist Sans/Mono load. Verified: the
compiled CSS now begins with the @import.
Upgrade @hanzo/event ^0.2.0 -> ^0.3.1 — the ONE telemetry client that POSTs
every signal (pageview · product event · identify · error) as one batched
stream to the ONE Hanzo Cloud front door /v1/event, lensed server-side into
web analytics, product insights, and error tracking (subsumes @sentry). The
0.2.0 client posted the deprecated /v1/analytics + /v1/tracker.
- ONE shared client (src/lib/event.ts): createAnalytics({ product:'console',
host:'' (same-origin), ingestKey }). host:'' posts to the console's own
/v1/event so the first-party session cookie rides along (go:embed cloud
native; standalone BFF forwards as the signed-in user); the client NEVER
sends an org — Cloud stamps the tenant from the validated session.
- The provider references the shared client; the three existing error
boundaries (product, dashboard, global) report React render errors via
reportError() to the same stream — including the provider-less global-error
boundary, the reason the client is shared. Auto error capture (window.onerror
+ unhandledrejection) + beacon-on-unload are on by default.
- Consent + PII: PII-free by construction (anon id + the stable owner/name
actor id, never an email; org never sent) and honors an explicit GPC /
Do-Not-Track opt-out — the consent layer for logged-out/public views.
Logged-out pageviews + errors ingest with an optional publishable key
(NEXT_PUBLIC_EVENT_INGEST_KEY).
- Product moments: + AGENT_CREATED, CHAT_STARTED/CHAT_MESSAGE_SENT,
SIGNUP_COMPLETED (atop the existing PROJECT_CREATED, API_KEY_CREATED,
PRICING_VIEWED/PLAN_CLICKED/CHECKOUT_STARTED, APP_CREATED/DEPLOY_STARTED,
FIRST_ACTION).
- proxy-allow: add the `event` head so the standalone BFF forwards /v1/event.
tsc clean; vitest 2933/2933; next build + build:embed green.
The light theme (html:root.t_light) built its whole color scale on hsl(220 …)
— hue 220 is blue — so every surface in light mode read blue-tinted (the 'weird
blue tinge' on admin.hanzo.ai). Zeroed the saturation → pure grayscale (same
lightness ladder), and neutralized the blue-tinted (16,24,40) shadows to pure
black alpha. Dark theme was already monochrome. Now monochrome in both modes.
Collapse the six-deep AuthGate → WaitlistGate → OrgGate → ScopeProvider → … →
OnboardingGate → DashboardShell chain into ONE pure resolver, one flat switch, and a
flat provider list. The whole entry decision is now a VALUE, rendered once.
- src/entry/resolve.ts: pure resolve(session) → signin|waitlist|org|onboard|ready. No JSX,
unit-tested (resolve.test.ts). Fail-closed lives here: `ready` (the app + its data) is
reachable ONLY for a loaded, authenticated, org-ENTERED session — proven by exhaustive
sweeps that resolve NEVER yields ready for a loading/anon/org-less session.
- src/entry/entry.tsx: gathers the session with hooks, resolves ONE stage, renders exactly
one surface via a flat switch.
- Self-contained stage views (no cross-imports): auth.tsx, waitlist.tsx, scope.tsx,
onboard.tsx, dashboard.tsx.
- src/entry/providers.tsx: the ready-only app-shell context as ONE flat ordered list
(reduceRight), mounted only at `ready`. Preferences + Toast (read by the resolver / the
onboard wizard / every module) sit above the switch.
- Drop compound suffixes (hooks unchanged): AuthGate→Auth, WaitlistGate→Waitlist,
OrgGate+ScopeProvider→Scope, OnboardingGate→Onboard, DashboardShell→Dashboard,
AppLauncherProvider→Launcher, PreferencesProvider→Preferences, CommandPaletteProvider→Palette,
FloatingChatProvider→Chat, ToastProvider→Toast, DetailPaneProvider→DetailPane.
- Delete the dead gate components; sweep stale names from comments. Same auth/waitlist/org/
onboard semantics + white-label brand-per-host intact — structure + names only.
tsc --noEmit clean; vitest 2868/2868; next build ✓ (compiled + type-checked).
console.hanzo.ai is a Next static export served as an SPA shell — the Go embed
serves the / route's index.html (the (dashboard) tree, guarded by <AuthGate/>)
for EVERY path (/, /signin, /auth/callback are byte-identical). A hard nav to
/auth/callback?code=… therefore mounts <AuthGate/>, NOT app/auth/callback/page —
AuthGate special-cased /signin but not the callback, so it fired
router.replace('/signin') before the PKCE code→token exchange could run. The
?code was discarded and sign-in dead-looped (the console-login P0).
Extend the existing /signin SPA-fallback pattern to the callback: AuthGate now
renders <AuthCallback/> for /auth/callback, completing handleCallback() BEFORE the
guard. Callback logic is extracted into one shared component used by both the
route and the gate (no duplication).
Fixes console.hanzo.ai login never completing.
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Backend (hanzoai/ai) renamed the router-config routes to resource-oriented
nouns and DROPPED the old compound routes (no aliases, no backwards compat).
Move every console caller in lockstep so nothing 404s after ship:
GET /v1/get-router-policy -> GET /v1/router/policy
POST /v1/update-router-policy -> PUT /v1/router/policy (verb -> PUT)
GET /v1/get-routing-defaults -> GET /v1/router/defaults
GET /v1/get-org-settings -> GET /v1/org/settings
POST /v1/update-org-settings -> PUT /v1/org/settings (upsert, PATCH-merge)
POST /v1/delete-org-settings -> DELETE /v1/org/settings
GET /v1/get-org-settings-list -> GET /v1/org/settings/list
- api/router.ts: RouterPolicyApi.get->originGet('router/policy'),
save->originPut('router/policy') (PUT, not POST).
- api/org-settings.ts + api/org-blend.ts: originGet('org/settings'),
originPut/originDelete; the read-modify-write still sends the full row
(safe under the backend's new PATCH-merge PUT).
- next.config.mjs: drop the dead get-/update- heads; 'router' head already
covers /v1/router/*; TARGETED /v1/org/settings* rewrites (an 'org' head would
hijack platform /v1/org/{org}/cluster).
- app/ai/[...path] ALLOWED: swap old exact paths for router/policy +
org/settings + org/settings/list.
- ai-accounts/routing-defaults route: UPSTREAM_PATH -> v1/router/defaults.
- e2e/router-config.spec.ts: GET+PUT dispatched on the one /v1/router/policy
noun; models-surfaces mock -> /v1/org/settings. Doc/hint sweep.
tsc --noEmit clean; next build + build:embed green.
Replace @hanzo/iam-js-sdk with @hanzo/iam and route the entire client
sign-in through one redirect + PKCE flow where IAM owns every credential
step. Mount <IamProvider> at the root; the sign-in screen is one
"Log in with Hanzo" button (useIam().login()) and /auth/callback completes
the PKCE token exchange (useIam().handleCallback()).
The session provider, account resolution, and API client now read the IAM
identity: the API client carries the IAM access token as a Bearer on every
/v1 call (cloud SanitizeIdentity validates the JWT), the account is
projected from the IAM userinfo claims, and refreshSession delegates to the
SDK's rotating refresh grant. No session cookie, no confidential-client
BFF code->cookie exchange, no ROPC.
Strip the non-IAM login mechanisms:
- inline email/password + social-button form (SignInForm) and its wrapper
- ROPC /v1/iam/login (iam-login) + hand-rolled PKCE (pkce)
- server-driven provider list (providers) and signup BFF (signup)
- the /auth/refresh, /auth/signin, /auth/signup BFF endpoint routes
- the old @hanzo/iam-js-sdk Sdk wrapper
tsc --noEmit clean; next build and build:embed both green.
Two-tab admin module (admin: true, hidden from customers, gated by
useIsSuperAdmin + the server getAdminGate):
- Promo: view + upsert the single platform plan promo (percentOff, UTC
start/end window, applicable paid plans, active) over GET/PUT /v1/admin/promos.
- Caps: pick a target org, list its usage caps (threshold, hard-cap vs alert,
softPct, rate limit, derived periodSpentCents/over/warn/resets), and
create/edit/delete over GET/POST/PATCH/DELETE /v1/admin/spend-caps?org=<slug>.
Wiring: promos + spend-caps added to ADMIN_AGGREGATE_HEADS (admin-aggregate.ts)
and ADMIN_V1_HEADS (next.config.mjs); PATCH + DELETE handlers added to the
global-admin-gated /admin/aggregate proxy (PUT already present). client.ts gains
origin{Put,Patch,Delete} (request extended to PUT/PATCH/DELETE). Caps reuse the
tenant SpendAlert primitive + budgets-logic verbatim — one caps model, no fork.
tsc clean; +45 unit tests (promo-logic, admin-promos, admin-spend-caps,
admin-aggregate heads); next build green.
A fully NATIVE product tour (Appcues-style) with ZERO external scripts and zero new
deps, so it works inside the go:embed static console and under any CSP. GuidedTour
spotlights data-tour anchors with a box-shadow cutout + a floating tooltip (centers
gracefully when an anchor is absent/hidden); FirstRunTour shows it ONCE per account
on the home, after onboarding (defers via the onboarding local guard so they never
overlap). Pure, versioned, owner-keyed seen-guard mirrors lib/onboarding/guard.
Files: src/lib/tour/{steps.ts,steps.test.ts}, src/components/tour/{GuidedTour,FirstRunTour}.tsx,
mounted in (dashboard)/layout.tsx, data-tour="nav" anchor on the sidebar.
tsc clean; vitest 10/10 (tour logic).
The home now surfaces the platform's live LLM signals — RED metrics, recent
logs, recent traces — directly on the overview, the way Langfuse put its metrics
dashboard up top. Reuses the ONE shared ProductObservability panel over the 'ai'
inference service (honest-empty until o11y emits; deep-links to /o11y). Adds
data-tour anchors (api-key, metrics) for the first-run tour. Additive, no new
deps, tsc clean.
/paas/<x> built ${PLATFORM_URL}/v1/<x>, so every call landed on a path that does
not exist: /paas/apps -> /v1/apps -> 404. The board rendered nothing and the token
was never the problem (PAAS_SERVICE_TOKEN is set, 64 bytes).
It aimed at /v1/<x> because that IS where the standalone Node platform served
apps. The control plane moved into cloud under /v1/paas and this path did not
follow — the console kept asking the old shape of a service that no longer has it.
Now /paas/<x> -> /v1/paas/<x>: the route is the PaaS plane, so it forwards to the
PaaS plane, name-preserving on both sides. `apps` is the proxy's only consumer
(platform.ts url()), so nothing else moves.
NOT verified end-to-end: /v1/paas/* is SuperAdmin-gated, so the rendered board
needs a superadmin session to confirm. What is verified: the upstream path exists
(/v1/paas/apps answers, 500 "SuperAdmin required" — reached and refused, not 404),
cloud's RBAC to read App CRs is fixed, and /v1/paas/health is 200.
The settings-as-Base admin pattern: a super-admin "Routing" editor on
admin.hanzo.ai that edits the platform + per-org auto-routing policy as DATA
(Base/SQLite OrgSettings rows), never env or a session-gated code toggle. This
is where auto-routing (enso) becomes a real admin toggle.
- OrgSettingsApi over /v1/{get-org-settings-list,get-org-settings,
update-org-settings,delete-org-settings} (super-admin gated upstream), on the
SAME originGet/originPost /ai bearer transport the router policy uses (no new
transport). Every write is read-modify-write, so sibling routing-policy fields
(routerPrefer, costCeiling, defaultSessionRouting, trainingContribution) are
never clobbered by the backend's full-row replace; revert to inherit deletes
the row only when it holds nothing else, else it clears just the field.
- RoutingModule (admin): three-state control (inherit / enabled / disabled) for
the global "*" default + per-org overrides, inline-editable, add-override for
an org not yet listed, honest empty + 403 states (OperatorAccessRequired). The
Hanzo brand seeds the org-first activation row — set org hanzo -> Enabled.
- Route the four heads through next.config AI_V1_HEADS + the /ai proxy ALLOWED set.
- 13 unit tests: state mapping, planSave field preservation, revert = delete.
31ae930438 ("signup mints NO credit") deleted lib/server/billing-grant.ts and its
test but left app/auth/signup/route.ts still importing and awaiting grantWelcomeCredit,
so `next build` could not resolve '~/lib/server/billing-grant' and the v8.4.136 image
never shipped — the last two pushes to main (signup-no-credit and the #160 merge)
both failed to compile.
This finishes that commit's stated intent: the route no longer imports or calls the
grant, so a new account starts at $0 (credit comes only from an admin grant or the
user adding funds), and the header doc drops the now-false "$5 welcome grant" line.
Upgrade the `router` product from the single policy-editor route into a two-tab
Router dashboard: Overview (routing observability) + Policy (the reused λ/µ
editor). One editor, one place — no duplication.
Overview reads GET /v1/router/stats (org-scoped, RequirePrincipal) and renders:
(a) cost saved as a blended $/MTok PROXY — saved_pct + routed vs counterfactual
index + cumulative saved, honest "—" when priced_events==0; (b) quality proxy —
reward_rate + coverage, engine_share, avg_confidence, shadow_agreement only when
non-null; (c) per-task routed-model distribution (Donut) + by-model Donut +
throughput LineChart; (e) an opt-in training-contribution toggle wired to
GET/POST /v1/{get,update}-training-contribution (feature vectors only, optimistic
+ honest revert); (f) the last-retrain gate verdict line.
- New pure, node-tested logic (components/products/router/logic.ts, +15 tests):
normalizeStats (partial/garbage → honest empty, cost stays null not $0),
formatters (em-dash on absent), distributions by share, throughput UTC labels,
retrainLine, range→hours. Reuses ui/Charts + ui/Metric + EmptyState/
BackendStateCard — no chart dep, honest states throughout.
- Transport mirrors get-router-policy exactly: three heads added to
next.config.mjs AI_V1_HEADS + app/ai/[...path] ALLOWED (v1/router/stats,
v1/{get,update}-training-contribution) — the /ai user-bearer proxy; no new
route handlers. go:embed hits cloud natively (honest BackendStateCard until the
ai router-stats wave ships).
- Also completes the half-applied v8.4.137 rename that left origin/main
non-building: RouterModule.tsx (the editor) → RouterPolicyEditor.tsx with its
broken ~/lib/api/router-policy import + InferenceRouterModule export fixed; the
registry entry repointed (id inference-router → router). No package.json bump.
tsc clean; vitest 2540/2540 (213 files, +15); next build ✓; build:embed ✓.
New AI-category product 'inference-router': org admins edit their own task →
model-pool prefer table + per-1k cost ceiling over the new hanzoai/ai
/v1/get-router-policy + /v1/update-router-policy (org-admin gated, self-scoped,
org > '*' > conf fold). v1-first transport: originGet/originPost + the two heads
added to AI_V1_HEADS dispatch and the /ai proxy ALLOWED set — no new route
handlers. tsc clean, vitest 2525/2525, next build + build:embed green.
The console ships into hanzoai/cloud as a Next.js STATIC EXPORT: generateMetadata
runs at build time with the default host, so the exported <title> is baked to
"Hanzo Cloud Console" for every host. The cloud serving layer rewrites it per
Host on first paint, but Next re-applies the baked metadata title on hydration,
reverting a Lux/Zoo tab to "Hanzo Cloud Console" — a white-label violation
(the visible shell was already client-branded; only the tab title leaked).
BrandTitle is a client net (mirrors ChunkGuard) that sets document.title from
window.location via the existing `branding.name` and re-affirms it through a head
MutationObserver, defeating the baked-metadata re-application. On the dynamic
standalone app the SSR title is already host-correct, so it is a no-op there.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Add the Interactive tab to Fine-tuning: a live LoRA client you create on a
base model, drive with forward_backward + optim_step (plotting the real loss
curve), sample from, and export a PEFT adapter.
TrainingApi hits the clean /v1-first `/v1/training/*` (next.config dispatches
the `training` head to the keyless /ai bearer proxy; the per-client
id/forward_backward/optim_step/sample/save_weights sub-paths are allow-listed,
with a DELETE handler). Engine 400/404/409 plain-text bodies surface verbatim.
Tolerant normalizers, vitest + a mocked Playwright e2e. v8.4.134.
Every client-facing same-origin API path is now /v1/<head>/… — one version,
no /<svc>/vN/ prefix, no nested /v1/<x>/vN/, no /api/. Supersedes the v8.4.16
/billing/v1 namespacing; completes the v8.4.120 /v1-rooted contract.
- Move 11 proxy handlers app/<svc>/… -> app/v1/<svc>/[...path] (a filesystem
route beats the /v1/[...path] cloud BFF; handlers re-root the upstream at v1/):
billing, commerce, ai-accounts (+settings/usage/routing-defaults), economy,
nodes, trading, superbase, vm. Auth/scoping/CSRF/allow-lists UNCHANGED —
only the path moved. UI tabs (/billing/*, /ai-accounts/*) still render.
- Remove the /v1/billing->/billing/v1 and /v1/commerce->/commerce/v1 rewrites.
- AI heads: playground images/videos + ai-connections build clean /v1/*;
next.config dispatches to /ai WITHOUT a nested version; app/ai re-roots at v1/.
New `ai` head so /v1/ai/connections dispatches. Fixes image/video/connections
on the go:embed console.
- Drop nested /v1/websearch/v1/scrape -> /v1/websearch/scrape; repoint apm
stale /api/v1 doc comments to the /v1/o11y the client actually calls.
- Left external (untouched): Gatus /api/v1, Cloudflare Turnstile /turnstile/v0,
Slack OAuth /oauth/v2.
tsc clean; vitest 2445/2445 (206 files); next build (route table shows every
/v1/<svc>/[...path] distinct) + build:embed green.
git grep -oE '/[a-z-]+/v[0-9]/' = external hosts only.
The admin.hanzo.ai launch dashboard: govern access to every hosted service and
approve users, wired to the cloud featuregate control plane + IAM iam#104.
- FeatureGateModule: a Services board (per-service waitlist-mode toggle — remove
the waitlist one service at a time) + a Pending-Users approval queue.
- src/lib/api/admin-featuregate.ts: client over /v1/admin/services* (list, toggle,
onboard) through the global-admin-gated /admin/aggregate proxy (+ normalizer test).
- IamAdminApi.pendingUsers/approveUser/rejectUser: REUSE the IAM approval API
(iam#104) via the existing global-admin /admin/iam proxy — no second approval store.
- Wiring: 'services' added to ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS;
get-pending-users / approve-user / reject-user added to the /admin/iam allow-list.
- Registered as 'launch-control' (admin-only, Security category).
- tsc --noEmit clean; vitest green (client normalizers + allow-list).
- ConnectionsModule: a real, prominent Connections page (AI category) over the
EXISTING AiConnectionsApi — connect OpenAI/Anthropic/Google by API key or OAuth,
disconnect, honest states. Keys sealed to KMS server-side, never in the browser.
- ai-connections.ts: host-aware base (embed -> cloud native /v1; standalone -> the
narrow /ai bearer proxy, mirroring billing/commerce IS_EMBED) so it works on the
go:embed console.hanzo.ai; + usage()/listWithUsage() import methods.
- AiUsageModule: render <UsagePanel> (native) AND <ConnectedUsage> (imported) together
— the cross-provider plane; per-provider isolation, honest empty until connected.
- /ai proxy allow-lists the /v1/ai/connections/:provider/usage sub-path (standalone).
- @hanzo/usage ^0.1.5. typecheck + build:embed green; 2440 tests pass (+5 new).
The QR sign-in card set an hz_session cookie sealed with the console secret that
cloud's /v1 can't read, while console.hanzo.ai authenticates via the casibase
session — so a QR login left the user effectively signed out for data calls, and
the /auth/device BFF 405s on the static embed anyway. Remove the button, the qr
view, and the dead QrSignIn component + iam-device wire + /auth/device route. One
way in: password + social + email signup. v8.4.131.
The go:embed'd console serves the SPA shell (the / route's index.html) for every
path — verified live: GET / and GET /signin return byte-identical HTML. So a direct
/signin load mounts the dashboard tree (AuthGate), not the /signin route. AuthGate saw
no account and called router.replace('/signin'), a no-op at /signin, and spun on the
loader forever (inputs=0, buttons=0). Reaching /signin as a redirect target (from /,
/projects, ...) worked because the URL changed.
Extract the sign-in experience into one <SignIn/> component (tenant form / admin silent
SSO / redirect-to-/ when authed) rendered by BOTH the /signin route and AuthGate: at
/signin AuthGate defers to <SignIn/> instead of a no-op redirect, so /signin resolves to
the form without depending on a navigation. Add an e2e regression that hard-loads /signin
and asserts the form renders.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>