Clicking "Sign in" on cloud.hanzo.ai appeared to do nothing. It was not a redirect
loop and nothing returned an error — every hop was HTTP 200, which is why the page
"looked fine".
console.hanzo.ai/ served a SECOND copy of the Hanzo Cloud marketing page, wearing the
byte-identical @hanzogui/shell header. So the journey was:
cloud.hanzo.ai [Sign in] -> console.hanzo.ai/ (same header, same "Sign in")
console.hanzo.ai [Sign in] -> /signin (one button, nothing else)
/signin [Log in with Hanzo Cloud] -> hanzo.id
Three clicks, and the first landed on a page indistinguishable from the one it left.
That reads as a re-render, and users stopped there.
The console is the APPLICATION; the marketing face of Hanzo Cloud is cloud.hanzo.ai.
Serving a third copy of it on the app host is what created the illusion. So `/` is no
longer a special surface: everything except the two auth routes is `guarded`, and a
definitively-anonymous visitor STARTS the authorize hop instead of being parked on an
interstitial that only asks "did you mean it?".
cloud.hanzo.ai [Sign in] -> hanzo.id
`startReauth()` is reused rather than `signinRedirect()`, so a deep link (/models)
returns to /models after login instead of dumping the user on the home.
SIGN-OUT IS PRESERVED, the one hazard here. Sign-out lands on /signin, which keeps its
button and never auto-authorizes -- IAM may still hold its own session, so authorizing
there would sign the user straight back in and make signing out impossible. A callback
failure lands on the same surface, so a broken hop cannot loop.
Removes PublicLanding + landing-surface, now unreachable (-269 lines net).
Tests: 258 files / 3211 assertions pass; tsc --noEmit clean.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The rescued gui8-props guard lands red here because origin fixed the same
line-height bug a different way and grew a shell the guard's fixtures predate.
Three corrections, none of them a weakened rule:
- gui8-props: blank out whole comment SPANS, not lines whose opener is `//`.
The module already says a doc comment must be free to name the bug it
documents; the fix comments at the call sites are multi-line `{/* … */}` JSX
blocks, so the continuation lines were still scanned and PitchHero's own
description of the bug counted as the bug.
- Charts: `lineHeight: 1.35` -> `'1.35'`. This one is a plain `<div>`, where
React already emits it unitless, so the CSS is unchanged — but the string form
is what the rule asks for and stays correct if it ever becomes a gui component.
- shell.test: `tracker` is a shell now (the rescued tracker pick), so the
descriptor table's expected ids include it.
tsc --noEmit: 0 errors. vitest: 3217 passed, 8 skipped, 0 failed.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 6ca6cc8fe108475a6cb734df5a38f77f6c7b13fc)
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)
(cherry picked from commit c256eb39aab2280121bfec766fdea3ea92f71091)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 775d9ab84092ab7a42430a97a7e0f99573fc7839)
(cherry picked from commit 0045bac1727d1251991f1106b8f2bccb1acb34ac)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 909388da5eb5ccdd1c87b5fb9b756b862733209d)
@hanzo/ui/product's CommerceResource documents itself as what BOTH the console's
Store category and the standalone Commerce admin render. That was false while
this directory kept its own copy — the console imported the local one, so the two
surfaces could drift silently and the shared component's docstring lied.
The local fork is deleted and the import repointed at @hanzo/ui/product, which is
what the Commerce admin already renders. One component, two surfaces, and the
docstring is now a fact.
tsc --noEmit: exit 0, 0 errors.
(cherry picked from commit e9524b5c4588ca6664ca23c341863faadaa48141)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 4dbae2115d963d766f6fcf9703ba50e170122234)
Rebuild the console Tracker into a SOTA, Linear-grade issue tracker over the
native cloud /v1/tracker surface, and give tracker.<brand> its own standalone
shell (the catalog chrome is stripped; the module's own views ARE the nav).
- Standalone shell: `tracker` ShellId + isTrackerHost + shellFromHost +
PRODUCT_SHELLS.tracker (indexLabel "Issues", home "tracker"). Registry entry
repointed to the native cloud backend with :view / :view/:sub routes and
subpages (My Issues, Teams, Cycles, Roadmap). Host-detected exactly like
billing./sentry./dns. — one image, one more face.
- Unified board: client-side cross-project merge (listAllIssues) → ONE filterable
board across every team AND every mirrored GitHub repo (the App-webhook lane's
GH mirror), with no second store. Group by status/priority/assignee/team,
filter (status/priority/kind/source/assignee/label/team), live search,
List <-> Board.
- Issue detail pane: one-click status, full edit, agent hand-off (assignee +
`agent` label -> the cloud coding seam opens a linked PR), the issue<->branch
<->PR chain (linkedPRs), git.hanzo.ai + upstream GitHub links, epic children.
- Keyboard-first: c=create, /=search, g-chord nav (g i/m/t/c/r), j/k/arrows/
Enter/e, and — standalone only — a capture-phase Cmd-K command palette that
never fights the console's global Cmd-K.
- Cycles (derived current iteration + progress) and Roadmap (epics + their
ExtRef children), both from real data — honest, never fabricated.
- Richer Issue type (kind/source/repo/extRef) so GitHub-mirrored + agent-PR rows
render distinctly; a "Sync GitHub" action triggers the org backfill.
Pure decisions decomplected to tracker/logic.ts (27 vitest green) + the shell
face test. Native @hanzo/gui v5 + React 19 — zero Svelte. Consumes the concurrent
App-webhook lane's GitHub mirror through the existing endpoints (no backend
duplication; the tracker data model + views + agent flow are owned here).
(cherry picked from commit 781db541e8476cb7e0bf1800c55f86f6d9162b50)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 613abbabb5d442e7a666a91ef4502b367a4f17b2)
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>
Move vendored MIT copyright notices out of LICENSE into NOTICE, and add the
full MIT permission text alongside them. LICENSE is reserved for this
project's own BSD-3-Clause grant.
cloud's per-product RED window and upstream o11y's metric-NAME CATALOG were both
answering GET /v1/o11y/metrics. Two different questions at one address, which is
why cloud had to suppress the module's real read to boot at all. cloud's moves to
/v1/o11y/product/metrics — the honest name for "one product's requests, error
rate and p95" — and the bare name goes back to the catalog it describes.
This is the read behind the platform-apps drawer, so it must land WITH the cloud
change (hanzoai/cloud fix/o11y-route-ownership): before it, this path 404s;
after it, the old path answers the catalog's shape instead of RED numbers.
`o11y` is still the allow-listed head in proxy-allow.ts, so the BFF is unchanged.
vitest 6/6 on the touched suite. tsc reports 3 errors in PublicLanding.tsx and
landing-surface.ts for a missing `@hanzogui/shell` — pre-existing here, present
on origin/main and untouched by this change, which edits only string literals.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
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>
console is NOT a fork -- its root commit is Hanzo's own and Hanzo is the
correct copyright holder. LICENSE is now a clean, canonical BSD-3-Clause
(verified against the SPDX text) naming Hanzo alone.
The seven retained MIT notices for vendored code (Tamagui, react-native-web,
Radix, Framer Motion, WorkOS et al) were attribution living in LICENSE. They
move to NOTICE, with the MIT permission notice reproduced so the obligation
travels with them.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
StepActions was the last child of a flex column, so its y was whatever the
step's content happened to add up to — Continue sat at a different height on
every step and a user clicking through had to re-aim each time.
It is a slot on StepShell now, above a content area with a reserved height:
one placement, decided in one place. Taller content still grows.
The workspace step also offered no way past it without naming the workspace,
which is optional — it skips now. Consent deliberately still has none:
accepting the Terms is not optional, so an affordance that skipped them would
be dishonest, and Continue stays disabled until the box is ticked.
Proven by geometry in a browser, because the JSX move is invisible to a unit
test: both shapes render the same button with the same label. Removing the
reserved height fails the spec; restoring it passes.
Hanzo-original work; BSD-3 is out of scope for hanzoai under HIP-0137.
LICENSE becomes the dual pointer, LICENSE-MIT / LICENSE-APACHE carry the
texts, and the upstream MIT copyright notices previously kept in LICENSE
are retained verbatim in LICENSE-MIT. README, LLM.md, NOTICE, Dockerfile
and package.json follow.
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 callback screen said "Sign-in failed." to everyone. Cancel a Google consent
screen and the product told you it broke; return with a session the issuer would
not reuse and it said the same thing. Both are ordinary OIDC answers, not faults,
and the words were the only thing wrong with them.
The SDK is right to report them uniformly — it validates `state` before honouring
an error branch, because /callback?error=… is a plain GET anyone can hand a
victim, so nothing downstream may DECIDE from that code. But it may READ it: the
screen now classifies the code for WORDING only, with authority left where it was.
access_denied says the sign-in was cancelled; login_required / interaction_required
/ consent_required say the session ended and lead with Sign in — the action that
actually resolves them, rather than a Retry that repeats the same refusal. Anything
unrecognized prefers the issuer's own error_description, which names a real cause
far better than a generic line ever did.
Both strings arrive from a redirect, so both are bounded before they reach a
screen whose only job is to say what happened.
This also unblocks silent SSO: a top-level prompt=none attempt returns
error=login_required to the app, and until now that landed on "Sign-in failed."
— a wrong screen for the most ordinary outcome that flow has.
Pure classifier, tested apart from the browser: 9 cases covering each class, the
issuer's own words, the unbounded-description bound, and a malformed query, which
must not throw on the one screen a person cannot navigate away from.
The Profile card rendered `avatar` read-only and offered "Edit in IAM" — which
links to an IAM that cannot set one either: its only writers are federation (a
GitHub avatar_url, an OIDC picture claim) and SCIM. A user who signed up with a
password had a monogram and no way out of it.
Now the card has "Add photo" / "Change photo". It posts to cloud's new
POST /v1/avatar, which stores the image in S3 and writes the URL onto the IAM
user row, so the change is visible to every product rather than this tab —
`reload()` re-reads the session so the rest of this tab agrees too.
The photo is downscaled to 512px in the browser first. A phone original is
several MB and would be served to every viewer of every page showing that face;
512 is larger than any surface renders it. The server still caps the body — this
is the courtesy, not the guard — and a source the canvas cannot decode is sent
verbatim so the server's format check stays the authority.
postForm is the ONE upload door, added because `request` JSON-encodes its body
so a file cannot travel that way. It shares everything else: the same
authedFetch (bearer + refresh) and the same baseHeaders tenant stamp, so an
upload is org/project-scoped exactly like a read. Content-Type is deliberately
deleted rather than set — the browser must write it itself to carry the
multipart boundary.
A refusal shows the SERVER'S reason: "a profile photo must be a PNG, JPEG, GIF
or WebP image" is actionable where "Request failed" is not.
The previous commit dropped console's explicit `storage: sessionStorage` so the
session would be shared across tabs, and the build died on the next push:
Error occurred prerendering page "/auth/callback"
ReferenceError: sessionStorage is not defined
`^0.21.2` resolved to a build whose default was the bare global
`config.storage ?? sessionStorage`, evaluated in the IAM constructor. In a
browser that is merely the wrong lifetime; under Next's static export it runs in
Node, where the identifier does not exist at all, so the constructor threw and
the export exited. Passing `sessionStorage` explicitly had been masking it —
the callback page constructs the SDK at module scope, and the value it passed
was the client-side one Next never evaluates on the server.
0.21.6 resolves storage through a guarded probe (`typeof localStorage`, then an
actual write, since privacy modes expose the object and throw on setItem) and
falls back to an in-memory Storage. So prerender gets a real object, the browser
gets localStorage, and neither needs a caller to know which one it is.
The caret already permitted 0.21.6; the lockfile is what pinned 0.21.2, which is
why CI installed the broken one while a local `pnpm install` would not have.
Verified by running the export that failed: 13/13 static pages, /auth/callback
among them.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Reaching a login form took three clicks across two hosts: "Sign in" on
cloud.hanzo.ai landed here, "Sign in" here routed to /signin, and /signin's
entire content was one button that starts the authorize redirect. The middle
step asked "did you mean it?" about an answer already given one click earlier.
Both CTAs now call signinRedirect() directly. /signin is unchanged and is still
the one sign-in surface — the guarded entry sends anon visitors there, sign-out
lands there, and deep links resolve to it. It deliberately does NOT auto-redirect
on load: after an explicit sign-out IAM may still hold a session, so an automatic
authorize would sign the user straight back in and there would be no way to leave.
The button there means something; the hop to reach it did not.
The console passed `window.sessionStorage` to the IAM SDK explicitly, so the
session was scoped to the tab that established it. A middle-clicked link opened
signed out. Nothing here documented that as a security posture — the comment
merely restated the SDK's default — so this drops the override and lets the SDK
own the decision, where the other surfaces inherit the same one.
The hand-rolled `memoryStorage()` goes with it. It existed only because the SDK
used to touch a bare `sessionStorage` global that is undefined under SSR; the
SDK now falls back to memory itself, so the shim has no remaining job.
Worth naming: console already owns a stronger mechanism than either Web Storage
area — `src/lib/server/session.ts`'s AEAD-sealed httpOnly `hz_session`, written
precisely so a session "cannot lapse out from under a working tab". The client
half regressed off it onto the SDK's per-tab store. This change fixes the tab
bug; moving the credential itself out of script-readable storage is the separate,
larger piece of work that cookie was built for.
`hz_return_to` stays in sessionStorage on purpose: "come back to where I was" is
a property of the tab that navigated away, not of the session.
The e2e seeds move with the store. They forged tokens into sessionStorage, which
the SDK no longer reads — left alone, every primed spec would have started
signed out and the suite would have failed for a reason that had nothing to do
with what it was testing.
Reported: signing in led to a create-account flow, an organization named
"coffee cups", and a refusal read as "organization name taken". The name was
free — there is no org matching it anywhere in the IAM store.
What actually fired is the FIRST-RUN GATE (iam internal/oidc/provision.go:156):
onboarding MOVES the caller into the org it founds, so founding a second would
strip this account from — and orphan — the org it already admins, along with that
org's billing account. IAM refuses with 409 "you already have an organization".
The console renders the server's message verbatim, so the customer was told about
organizations immediately after typing a name, and read it as being about the name.
This screen is only ever rendered when the client resolved an EMPTY owner
(entry/scope.tsx: `if (!owner) return <OrgOnboarding />`) — and an owner that is
empty because a read failed is indistinguishable from a brand-new account. So the
refusal is the first RELIABLE signal that the session was wrong, and the only
honest thing to do with it is recover, not apologise: read the account, and offer
the way into the org this identity is actually in.
THE STATUS ALONE CANNOT DECIDE THAT, which is the part worth naming. /v1/iam/onboard
answers 409 for two opposite reasons — the first-run gate above, and a name
genuinely held by another tenant (provision.go:190). What separates them is whether
this account is itself in an org: only the first has somewhere to go. readOnboardRefusal
takes that reading and returns recover-or-report, so a customer who really did pick
a taken name still sees the accurate message and is not bounced into someone
else's org.
Recovery is offered, never automatic — re-authenticating on our own would loop
forever against whatever left the session ownerless in the first place.
24 tests pass in onboarding.test.ts, 4 of them new and covering both 409s.
NOT yet browser-verified: this needs a real signed-in account whose session
resolves without an owner, which is the state I cannot manufacture locally.
The topbar carried a search box reading "Search or jump to… ⌘K" and, directly
beside it, an "Apps" button. Both called the SAME `useCommandPalette().open`.
Two adjacent triggers for one surface read as two different destinations, and
"Apps" in particular implied an app directory that does not exist — the
palette is what opens either way.
The mobile drawer had the identical pair, so it goes there too; otherwise the
rule would hold on a laptop and not on a phone.
Nothing is lost: the remaining control is the one that SAYS what it does and
shows its shortcut. This is the fifth of the five switchers, and the count on
the Overview at 1440x900 is now three — one per question:
before project (top-right) · account (bottom-left) · network (top-right) · apps
after context (top-left) · account (bottom-left) · network (top-right)
Measured in a browser against the real shell, not counted by reading the JSX.
tsc --noEmit exit 0
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Five controls on one screen answered "who and where am I", and three of them
answered it from three different corners: the org mark top-left, the account
(which also switched tenant) bottom-left, and a project chip top-right beside
the network. Org and project are not two questions — they are "which tenant,
and which slice of it" — so they condense into ONE control at the top-left,
under the mark that already anchors the tenant.
Each question now has exactly one place:
WHERE ContextSwitcher, top-left org + project
WHO AccountMenu, foot of the rail identity, team, settings, balance, exit
MODE ScopeSwitcher, top-right network, with its tier dot
The network deliberately stays its own always-visible chip: it is a global
mode rather than a place, and the dot (mainnet green / testnet amber) is a
destructive-environment guard you must be able to READ without opening a menu.
There is still exactly ONE org switch. `org-scope.switchOrg` — the seam that
persists the scope and reloads so every module refetches under the new
`X-Org-Id`, and the seam tenant scoping and billing attribution hang off — is
imported, never reimplemented. The admin-gated, server-PAGED cross-tenant
search moved across whole rather than being reduced to a first page, so an
admin can still reach a tenant nobody is a member of. Verified against a
mocked cross-tenant list: typing "acme" narrows the org group to exactly
"Acme Industrial".
`adminOrgState` had no caller once the account menu stopped switching tenant,
so it is deleted rather than kept warm. The invariant it protected is now
pinned directly: `org-state.test.ts` scans every source file for `switchOrg(`
and asserts the caller set exactly. Mutation-tested — re-adding a switch to
AccountMenu turns it red, removing it turns it green.
One row, not two: the org/project/network menus rendered the same row shape
from two copies. That is `ui/MenuRow` now, so they cannot drift.
FINDING — a missing primitive, not worked around. A single-select list wants
ARIA `listbox`/`option`, and @hanzo/gui types `role` as React Native's
accessibility-role union: it admits `option` but NOT `listbox`, so an `option`
could never be given the parent ARIA requires. Used `radiogroup`/`radio`, the
single-select pair gui carries whole. @hanzo/gui should carry `listbox`.
`FieldText` grew an `ariaLabel`: a search field with no visible label had no
accessible name, and that is the console's own primitive to extend.
tsc --noEmit exit 0
vitest 3156 passed, 8 skipped (255 files)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
`deps: console onto gui 8.x` moved @hanzo/gui 7.3.0 -> ^8.0.0. gui 8 pulls
@hanzogui/lucide-icons-2@8.0.0, whose ESM build does
import { Svg, Path } from "react-native-svg"
while declaring react-native-svg in NEITHER `dependencies` NOR
`peerDependencies`. Nothing installs it, so webpack cannot resolve it, and
`next build` dies with a wall of "Module not found: Can't resolve
'react-native-svg'" — once per icon. The `react-native$: react-native-web`
alias in next.config.mjs does not cover it: `$` is an EXACT-match alias and
react-native-svg is a different package.
So console main has been UNBUILDABLE since that bump. That is why
ghcr.io/hanzoai/console has no v8.5.33, v8.5.34 or v8.5.35 — the tags were
cut, the images were never published, and universe's bump to v8.5.35 had to
be reverted to v8.5.32 to keep a pullable image. The three fixes riding those
tags (the 1px display line-box, the missing 404, @hanzo/iam 0.21.2) have been
finished in main and dark in production the whole time.
The estate already had the answer: every app on gui 8 carries this dependency
explicitly (hanzo.ai, hanzo.sh-std, app-std, console-std all pin 15.15.5),
and every app still on gui 7.3.0 does not need it. Console took the bump
without the companion. This restores the one established pairing.
Measured, not assumed:
before next build -> exit 1, "Can't resolve 'react-native-svg'"
after next build -> exit 0, 42 routes emitted
pnpm install --frozen-lockfile -> exit 0 (the Dockerfile's exact command)
Lockfile regenerated with the DECLARED pnpm 11.17.0 (node 22), not the pnpm 9
on PATH — pnpm 9 renormalizes peer-suffix keys and churns 1061 lines. This
diff is +96/-0, purely additive.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The guide headline said "100+ models. One OpenAI-compatible API." — wrong against
api.hanzo.ai, which serves 444 priced entries (and lists 107 callable ids). 400+
is true under the catalog reading and is what the rest of the estate now says.
Two comments carried counts they had no way to maintain — "85+ models across a
dozen families" and "~340 models" — each written when it was true and silently
wrong since. A comment that states a number the code does not compute is a comment
that will lie; both now describe the shape without asserting a size.
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>
identify() sent a user id and nothing else, so the warehouse held a population
of opaque subjects. Every funnel could count users and none could say which
user, and answering "who hit this" meant an IAM lookup per row — which is why
no user attribute is visible anywhere downstream.
Email and name are first-party facts about our own users, and they arrive in
the same IAM claims this file already decodes to get the id: accountFromClaims
projects them onto the Account and the bridge dropped them on the floor. The
SDK has taken traits since it took a person id; nothing new is collected, a
value already in the token is simply not discarded on the way past.
The scrubber does not touch this: capturePII gates error TEXT, and identify
traits ride build()'s `...extra` verbatim. Secret redaction is unconditional
and stays that way.
A key is omitted rather than sent undefined, so an absent claim cannot blank a
trait an earlier identify established. The org is still not sent — the tenant
is stamped server-side from the validated bearer, and a tenant the client can
name is a tenant the client can get wrong.
Two faults, one outcome: every console row landed in the $public tenant, a
partition no org can read, with no user attached. 498 rows, zero identified users.
CREDENTIAL. The client posted same-origin and trusted the first-party cookie to
carry the tenant. That cookie is the casibase session; cloud resolves a tenant from
a validated IAM bearer. A cookie-only POST carries no principal, and the door does
not refuse it — it takes the anonymous lane, which files rows under $public and
drops identify with a 200 receipt. Both ends look healthy. Pass the IAM access
token the console already holds; read it through a function, since the module is
built at import time when nobody is signed in yet.
No publishable key is passed instead, and the note in the file says why: a pk- maps
to one org while this image serves three brands resolved at runtime, and the SDK
resolves `ingestKey ?? token`, so a key would silently override each signed-in
user's own identity rather than supplement it.
IDENTIFIER. identify() sent `${owner}/${name}`, an org-relative reference in a
different id space from the IAM sub that hanzo.ai and hanzo.chat send — so one user
counted twice across surfaces, and the ref moves when an org or handle is renamed,
rewriting history. The sub was already decoded here and used only to back-derive
owner/name, then dropped; carry it through as Account.userId and identify by it.
Absent stays absent rather than falling back to the actor ref.
0.3.8 stops the client shipping a raw location on every event. The location is
stamped on all of them now, so an invite/reset/magic link — a JWT in the query,
an address in `?email=` — reached the warehouse in cleartext on the first click
and again on every later one. url, path and referrer now get the same redaction
the error plane has always applied to error text.
Console is the surface where that matters most and the one furthest behind: the
running bundle serves libraryVersion 0.3.0, six releases back, so it has no
error plane at all (sentry.ts did not exist yet) and never stamped a page onto
an event. The lockfile has been ahead of the image for a while — this bumps the
lockfile; the image has to be rebuilt and its pin moved for any of it to be
true in production.
Lock edited only where it names @hanzo/event, so an unrelated tree is not
re-resolved into a dependency bump. Integrity is the one npmjs serves for
0.3.8; `pnpm install --frozen-lockfile` accepts it on pnpm 11.17.0, the version
package.json pins and the Dockerfile installs via corepack.
Autocapture ($click/$input/$change) reaches the wire through capture(),
which supplied no location, so every $click landed with an empty url and
path and was unattributable to a page -- the one thing a heatmap needs.
0.3.5 stamps the page in build(), the single point every event is built,
placed ahead of the caller's fields so pageview()'s explicit path still
wins on the route changes that fire before window.location catches up.
The lockfile moves with the range here. What ships is the lockfile, not
the range, which is why production was serving 0.3.3 while the declared
range already read ^0.3.4. Resolves 0.3.6 (the 0.3.5 runtime plus a
test), deduped with @hanzo/observe and @hanzogui/telemetry.
cloud renamed /v1/o11y/annotation-queues[…] to /v1/o11y/reviews[…] — o11y's own
comment already called them human-review queues, so "queue" was the
implementation and "review" the resource.
lib/api/o11y.ts makes the three reads (list, detail, items) and must move or
the Annotation Queues board renders empty. The rest here is prose that named
the cloud address, including o11y.ts's "mirrors" note and next.config.mjs's
dev-proxy comment.
The console's own `annotation-queues` page — route id, label and docs link —
stays, for the same reason the score-configs page did: renaming a page id
without its heading and its documentation leaves a page that matches neither.
cloud nested dataset items inside the set that contains them
(/v1/evals/dataset-items -> /v1/evals/datasets/:name/items) and renamed score
configs to rubrics (/v1/evals/score-configs -> /v1/evals/rubrics).
The item change is a real contract change, not a path swap: the dataset was
always required — as a body field on POST and a query param on GET — and is now
a path segment. So EvalsApi.createDatasetItem takes it as its FIRST ARGUMENT
and CreateDatasetItemBody no longer carries datasetName; listDatasetItems
builds the nested URL. DatasetsModule passes the set it already had in hand.
Two comments here were wrong before this commit and are corrected rather than
merely renamed: registry.tsx, ScoreConfigsModule.tsx and o11y.ts all claimed
this surface was `/v1/o11y/score-configs`. o11y has never served it — o11y.ts's
own note says scores and their definitions STAY on /v1/evals — so the address
they named did not exist under either spelling.
DELIBERATELY NOT RENAMED: the console's own `score-configs` PAGE (its route id,
its "Score Configs" label and its ${DOCS}/score-configs link). That is product
copy plus an external docs URL, and moving the id without the label and the
docs page would leave a page whose name matches neither its own heading nor the
documentation it links to. It is a coordinated rename with hanzo-docs, not part
of an api sweep.
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.
cloud renamed /v1/load-balancers[/:id] to /v1/balancers[/:id] — nesting under
/v1/networks was unavailable (apps/zt owns that prefix), so the flat single
noun is the one-way answer, matching its sibling /v1/vpcs.
proxy-allow.ts is the load-bearing edit: it is a literal FIRST-SEGMENT
allowlist, and `load-balancers` was a real entry, so without this every call
403s at the proxy before it ever reaches cloud. LoadBalancerModule.tsx calls
list/create/delete directly.
The admin Infra tab slug moves too — same concept, one name — while its visible
label stays "Load balancers", which is both what a person reads and what
e2e/admin-infra.spec.ts clicks. The response body is unchanged: cloud still
returns {loadBalancers:[…]}, DigitalOcean's own name for its own resource.
cloud renamed /v1/captable/share-classes[/:id] to /v1/captable/classes[/:id]
and /v1/captable/equity-plans to /v1/captable/plans: the captable prefix
already supplies "share" and "equity", so each member was repeating its group.
lib/api/captable.ts builds both URLs and must move or the Classes and Plans
panels 404. CapTableModule.tsx prints the address in a BackendStateCard hint,
which is only useful if it names the route that actually failed. proxy-allow.ts
admits by first segment, so `captable` is unchanged — only its comment, which
lists the sub-paths that head covers.
The ShareClass and EquityPlan TYPES keep their names: those are the domain
objects, and a share class is a share class wherever it is addressed.
cloud renamed POST /v1/webhooks/:id/rotate-secret to POST /v1/webhooks/:id/secret
— the endpoint has one signing secret and POST is what mints a new one, so the
verb belonged to the method, not the noun.
WebhooksModule.tsx calls the address directly, so it moves or the rotate button
404s. proxy-allow.ts needs no allowlist change (it admits by first segment, and
`webhooks` is unchanged); only its comment, which enumerates the sub-paths that
head covers, would otherwise name an address nobody serves.
@hanzo/canvas was declared "^0.1.0". A caret on a 0.x version pins the
MINOR, so console was locked to canvas 0.1.0 while 0.2.1 was current —
the same trap that froze @hanzogui/shell on 7.5.1. @hanzo/ui@8.0.38
peer-depends on canvas ">=0.1.0", which 0.1.0 satisfies, so nothing
warned. Silent freeze.
Six exact pins (dash, gui, and the four @hanzogui/* entry points) could
never take a patch either. Floated them to carets; 8.x is a stable major
so a caret is the correct expression.
Moved on install: brand 1.4.4->1.4.5, canvas 0.1.0->0.2.1,
data 1.2.1->1.2.2, logo 1.0.13->1.0.14, ui 8.0.20->8.0.38.
@hanzo/finance-ui stays on 0.1.1, now written "~0.1.1" so the range says
so. 0.2.1 declares peer "@hanzo/ui": ">=8.0.0" — satisfiable, so the peer
check passes — but its source imports DataTable, LineChart, Column,
ChartPoint and Sparkline from @hanzo/ui, and no published @hanzo/ui
exports them (verified against 8.0.20, 8.0.38 and ui-shadcn 5.9.1).
It publishes raw src/, so those errors land in the consumer's tsc.
A satisfiable-but-false peer range is worse than an unsatisfiable one:
it fails at build time in the consumer instead of at install.
tsc --noEmit clean; next build succeeds; 3152 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console sat on @hanzo/gui 7.3.0 / @hanzogui/shell 7.6.3 while the gui line
shipped 8.0.0, so every shell fix landed on main and stopped there: cloud.hanzo.ai
and console.hanzo.ai kept serving a header from a package that could not move.
What actually pinned it was a phantom peer. Every published @hanzogui/shell
declared `@hanzo/iam@^0.13.1` — a package it never imported — and for a 0.x range
the caret pins the minor, so once IAM reached 0.21.2 the range was unsatisfiable
and the 8.x line was uninstallable. Fixed at the source and released as
@hanzogui/shell@8.0.1 with zero dependencies.
typescript goes ^7.0.2 -> ^5.8.2 in the same commit because it has to: Next 15
does not support TS 7, and on 7.0.2 `next dev` fails to a bare
"Cannot read properties of undefined (reading 'endsWith')" after silently
installing a 5.x behind your back. Same fix hanzo.ai just made.
Verified on the real 8.x tree at 1440x900 and 390x844: Products opens as FIVE
columns with all ten categories visible and nothing clipped (it was four columns
with WEB3 + APPS below the fold), one "Sign in" pointing at /signin, one h1,
the hero primary a white 999px pill, and Terms inside the viewport with no
underline and no horizontal overflow.
rescue/stash-0: brandFromHost routes through normHost, which now trims
before stripping the port and drops the FQDN root dot. A padded, ported
or dotted host can no longer soften the suffix match and swap adminDomain
onto the default brand. Tests cover the padded/ported/dotted/lookalike cases.
The env var is read but is undefined in every shipped artifact, which
reads like an oversight and invites a build arg. It is not one.
A pk- resolves to exactly ONE org -- cloud stamps the tenant from the key
-- and this image is brand-agnostic: one build serves cloud.hanzo.ai,
cloud.lux.cloud and cloud.zoo.cloud with the brand resolved at runtime
from the request hostname. Baking a key would file every brand's traffic
into whichever org owns the key: wrong data, and a cross-tenant leak. It
is the same reason the Dockerfile bakes no NEXT_PUBLIC_*.
Signed-in traffic does not need one. host:'' posts same-origin, so the
first-party session rides along and cloud resolves the tenant from it at
full capability; identify and track already land correctly.
What is genuinely unattributed is the logged-out lane, which reaches
cloud with no credential and takes the anonymous lane -- pageview and
error stored, track/identify/group dropped, 200 either way. Closing it
needs a per-host key delivered at runtime over a channel both artifacts
share, which is recorded here so the next reader does not reach for the
build arg instead.
A merchant connecting THEIR Stripe account is a capability we offer; only
Hanzo's own charging is Square-only. Square leads the list because it is
the rail this platform bills on. The doc-comment cleanups stay: describing
a payload SHAPE by a vendor's name was always vague, whoever we charge on.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Square is the rail (via commerce), so the console says so: the payments
integration list drops Stripe, and every doc comment that reached for the
brand to describe a SHAPE — a nested subscription record, a seconds-vs-
millis period stamp, snake_case card fields, the developer-workbench
pattern — now describes the shape instead. No behavior change; the
normalizers still accept the same payloads, which is what the 25 billing
tests prove.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The count of a list envelope now reads the named field before Casdoor's
untyped second slot: total, then data2, then the rows themselves. ONE
helper (envelopeTotal in lib/api/client.ts) owns the order; getList,
iamList, makeIamClient and AuditApi.list all go through it, so the data2
fallback lives in exactly one place and dies with the legacy emitters.
e2e fixtures still emit data2 on purpose — they pin today's live wire
and flip only when the fallback is deleted.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The status and the counts field say signup (was signed_up / signedUp),
matching the cloud rename that migrates the store rows — the whole
platform now spells the concept one way.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>